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::{
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/// Failure building a `ParsedRequest` from an incoming request.
28///
29/// Split out from a bare `String` (RFC 068 S-02) so the caller can
30/// answer **413** for an oversized body specifically, rather than the
31/// generic 500 every other failure here gets — the client's mistake,
32/// not the server's.
33#[derive(Debug)]
34#[non_exhaustive]
35pub enum ParsedRequestError {
36    /// The body exceeded `max_body_bytes`.
37    BodyTooLarge,
38    /// Any other failure — unchanged from this function's previous
39    /// bare-`String` error.
40    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
52/// Consume an incoming hyper request into a `ParsedRequest` the matcher
53/// can use.
54///
55/// # Why a non-JSON body is logged but not rejected
56///
57/// Some rule sets key only on URL path or headers and don't inspect the
58/// body at all. Failing the whole request because an operator sent a
59/// form-encoded payload would be more aggressive than needed; we log a
60/// warning and continue so the URL-path-only rules still apply. Only
61/// *claimed* JSON (`Content-Type: application/json`) that fails to
62/// parse becomes a hard `Err` — that is a real client bug.
63///
64/// # `max_body_bytes` (RFC 068 S-02)
65///
66/// The body used to be collected whole with no cap — the external
67/// audit measured one 256 MiB request taking the process from 9 MiB
68/// RSS to 462 MiB. `http_body_util::Limited` bounds how much of the
69/// body is ever buffered to (at most, just past) `max_body_bytes`
70/// before this returns `Err(BodyTooLarge)` instead of continuing to
71/// read — the caller answers 413 without the body ever being fully
72/// collected.
73pub 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        // RFC 077 P-09: hoisted so the header lookup happens once per
99        // request instead of once per match arm below.
100        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            // declared application/json but body didn't parse → hard error
105            (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    // RFC 075 F-03: percent-decode before normalising, never after —
127    // see `percent_decode_url_path`'s own doc comment for why the order
128    // is security-critical, not stylistic.
129    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    // RFC 050: propagate what's already been measured above (`has_body`,
133    // `body_bytes`'s length) rather than computing anything new.
134    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
144/// Emit a single log line describing the request.
145///
146/// Kept as a public, two-argument function so any caller outside this
147/// workspace keeps compiling unchanged (RFC 051 review, R-09 applies to
148/// function signatures on `pub fn`s, not only to struct fields). It
149/// still redacts — `TraceConfig::default()` carries the default
150/// denylist — so an out-of-tree caller gets the security fix for free
151/// rather than needing to opt in. In-workspace, use
152/// `capture_in_log_with_trace_config` (crate-private by design — not
153/// linked here since it isn't part of this crate's public surface),
154/// which shares the server's own `TraceConfig` instead of a fresh
155/// default one.
156pub fn capture_in_log(request: &ParsedRequest, verbose: VerboseConfig) {
157    capture_in_log_with_trace_config(request, verbose, &TraceConfig::default())
158}
159
160/// [`capture_in_log`], but redacting per `trace_config` instead of a
161/// fresh `TraceConfig::default()` — the one place this and the trace
162/// channel (`crate::trace::redact_headers`) can never honour two
163/// different denylists, because both read the same `TraceConfig`
164/// instance the running server built (RFC 051).
165pub(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
173/// Build the line `capture_in_log` emits, without emitting it — split out
174/// so the rendered text (what actually reaches a terminal) can be
175/// asserted on directly in tests, rather than intercepting the log
176/// backend.
177fn 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        // RFC 073 S-05: query strings and bodies used to print raw,
243        // unredacted, here — the same denylist/allowlist that already
244        // covers headers above (`trace_config.is_redacted_key`) now
245        // covers these too, so a credential doesn't reach the console
246        // just because it travelled in the query string or body
247        // instead of a header.
248        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// ── Tests ─────────────────────────────────────────────────────────────
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::trace::HeaderRedactionMode;
293
294    /// Build a minimal `ParsedRequest` carrying the given headers.
295    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    /// RFC 051 evidence requirement: with `log.verbose.header` on and no
308    /// other configuration (`TraceConfig::default()`), the rendered log
309    /// line contains none of the credential values, but a non-credential
310    /// header's value still appears.
311    #[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    /// Redacted headers stay present, marked with the placeholder — not
340    /// silently dropped from the rendered line (RFC 040 Goal 4, reused
341    /// here per RFC 051).
342    #[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    /// A denylist compared case-sensitively is a leak that passes a naive
355    /// test — proven here with non-lowercase spellings.
356    #[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    /// The policy is shared, not copied: an allowlist configured on the
376    /// same `TraceConfig` passed to the trace channel also governs verbose
377    /// logging, with no separate list to keep in sync.
378    #[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    /// `capture_in_log`'s public, two-argument signature must keep
402    /// compiling for any out-of-tree caller (RFC 051 review, § 2) — a
403    /// regression guard on the API surface itself. Its body has no
404    /// return value to assert redaction on directly; that behaviour is
405    /// `render_request_log`'s, exercised by the tests above with the
406    /// same `TraceConfig::default()` this delegates to.
407    #[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    // ── RFC 073 S-05: query-string and body redaction in verbose output ──
414
415    const VERBOSE_BODY_ONLY: VerboseConfig = VerboseConfig::new(false, true);
416
417    /// Build a `ParsedRequest` with a query string, no body.
418    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    /// The tranche 5 handoff's own acceptance example, at the console
429    /// log: a `?token=secret` query parameter must not appear verbatim
430    /// under `log.verbose.body`'s default (redacted) settings — the
431    /// same denylist that already redacts headers above now covers the
432    /// query string too (RFC 073 S-05).
433    #[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    /// The handoff's other acceptance example: a secret in the JSON
450    /// body must not appear verbatim under default settings either.
451    #[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}