Skip to main content

apimock_server/
parsed_request.rs

1//! Server-side helpers for `apimock_routing::ParsedRequest`.
2//!
3//! # Why this file exists after the 5.0 split
4//!
5//! `ParsedRequest` (the data) now lives in `apimock-routing` so the
6//! matcher crate can depend on it without pulling in hyper/body I/O.
7//! The two operations that *touch* HTTP — building a `ParsedRequest`
8//! from an incoming hyper request, and logging one to stdout — are
9//! server-layer activities, so they stay here as free functions.
10
11use 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
24/// Consume an incoming hyper request into a `ParsedRequest` the matcher
25/// can use.
26///
27/// # Why a non-JSON body is logged but not rejected
28///
29/// Some rule sets key only on URL path or headers and don't inspect the
30/// body at all. Failing the whole request because an operator sent a
31/// form-encoded payload would be more aggressive than needed; we log a
32/// warning and continue so the URL-path-only rules still apply. Only
33/// *claimed* JSON (`Content-Type: application/json`) that fails to
34/// parse becomes a hard `Err` — that is a real client bug.
35pub 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            // declared application/json but body didn't parse → hard error
61            (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    // RFC 050: propagate what's already been measured above (`has_body`,
88    // `body_bytes`'s length) rather than computing anything new.
89    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
99/// Emit a single log line describing the request.
100///
101/// Kept as a public, two-argument function so any caller outside this
102/// workspace keeps compiling unchanged (RFC 051 review, R-09 applies to
103/// function signatures on `pub fn`s, not only to struct fields). It
104/// still redacts — `TraceConfig::default()` carries the default
105/// denylist — so an out-of-tree caller gets the security fix for free
106/// rather than needing to opt in. In-workspace, use
107/// [`capture_in_log_with_trace_config`], which shares the server's own
108/// `TraceConfig` instead of a fresh default one.
109pub fn capture_in_log(request: &ParsedRequest, verbose: VerboseConfig) {
110    capture_in_log_with_trace_config(request, verbose, &TraceConfig::default())
111}
112
113/// [`capture_in_log`], but redacting per `trace_config` instead of a
114/// fresh `TraceConfig::default()` — the one place this and the trace
115/// channel (`crate::trace::redact_headers`) can never honour two
116/// different denylists, because both read the same `TraceConfig`
117/// instance the running server built (RFC 051).
118pub(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
126/// Build the line `capture_in_log` emits, without emitting it — split out
127/// so the rendered text (what actually reaches a terminal) can be
128/// asserted on directly in tests, rather than intercepting the log
129/// backend.
130fn 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// ── Tests ─────────────────────────────────────────────────────────────
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::trace::HeaderRedactionMode;
238
239    /// Build a minimal `ParsedRequest` carrying the given headers.
240    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    /// RFC 051 evidence requirement: with `log.verbose.header` on and no
253    /// other configuration (`TraceConfig::default()`), the rendered log
254    /// line contains none of the credential values, but a non-credential
255    /// header's value still appears.
256    #[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    /// Redacted headers stay present, marked with the placeholder — not
285    /// silently dropped from the rendered line (RFC 040 Goal 4, reused
286    /// here per RFC 051).
287    #[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    /// A denylist compared case-sensitively is a leak that passes a naive
300    /// test — proven here with non-lowercase spellings.
301    #[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    /// The policy is shared, not copied: an allowlist configured on the
321    /// same `TraceConfig` passed to the trace channel also governs verbose
322    /// logging, with no separate list to keep in sync.
323    #[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    /// `capture_in_log`'s public, two-argument signature must keep
347    /// compiling for any out-of-tree caller (RFC 051 review, § 2) — a
348    /// regression guard on the API surface itself. Its body has no
349    /// return value to assert redaction on directly; that behaviour is
350    /// `render_request_log`'s, exercised by the tests above with the
351    /// same `TraceConfig::default()` this delegates to.
352    #[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}