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, 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/// Failure building a `ParsedRequest` from an incoming request.
25///
26/// Split out from a bare `String` (RFC 068 S-02) so the caller can
27/// answer **413** for an oversized body specifically, rather than the
28/// generic 500 every other failure here gets — the client's mistake,
29/// not the server's.
30#[derive(Debug)]
31#[non_exhaustive]
32pub enum ParsedRequestError {
33    /// The body exceeded `max_body_bytes`.
34    BodyTooLarge,
35    /// Any other failure — unchanged from this function's previous
36    /// bare-`String` error.
37    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
49/// Consume an incoming hyper request into a `ParsedRequest` the matcher
50/// can use.
51///
52/// # Why a non-JSON body is logged but not rejected
53///
54/// Some rule sets key only on URL path or headers and don't inspect the
55/// body at all. Failing the whole request because an operator sent a
56/// form-encoded payload would be more aggressive than needed; we log a
57/// warning and continue so the URL-path-only rules still apply. Only
58/// *claimed* JSON (`Content-Type: application/json`) that fails to
59/// parse becomes a hard `Err` — that is a real client bug.
60///
61/// # `max_body_bytes` (RFC 068 S-02)
62///
63/// The body used to be collected whole with no cap — the external
64/// audit measured one 256 MiB request taking the process from 9 MiB
65/// RSS to 462 MiB. `http_body_util::Limited` bounds how much of the
66/// body is ever buffered to (at most, just past) `max_body_bytes`
67/// before this returns `Err(BodyTooLarge)` instead of continuing to
68/// read — the caller answers 413 without the body ever being fully
69/// collected.
70pub 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        // RFC 077 P-09: hoisted so the header lookup happens once per
96        // request instead of once per match arm below.
97        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            // declared application/json but body didn't parse → hard error
102            (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    // RFC 050: propagate what's already been measured above (`has_body`,
126    // `body_bytes`'s length) rather than computing anything new.
127    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
137/// Emit a single log line describing the request.
138///
139/// Kept as a public, two-argument function so any caller outside this
140/// workspace keeps compiling unchanged (RFC 051 review, R-09 applies to
141/// function signatures on `pub fn`s, not only to struct fields). It
142/// still redacts — `TraceConfig::default()` carries the default
143/// denylist — so an out-of-tree caller gets the security fix for free
144/// rather than needing to opt in. In-workspace, use
145/// `capture_in_log_with_trace_config` (crate-private by design — not
146/// linked here since it isn't part of this crate's public surface),
147/// which shares the server's own `TraceConfig` instead of a fresh
148/// default one.
149pub fn capture_in_log(request: &ParsedRequest, verbose: VerboseConfig) {
150    capture_in_log_with_trace_config(request, verbose, &TraceConfig::default())
151}
152
153/// [`capture_in_log`], but redacting per `trace_config` instead of a
154/// fresh `TraceConfig::default()` — the one place this and the trace
155/// channel (`crate::trace::redact_headers`) can never honour two
156/// different denylists, because both read the same `TraceConfig`
157/// instance the running server built (RFC 051).
158pub(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
166/// Build the line `capture_in_log` emits, without emitting it — split out
167/// so the rendered text (what actually reaches a terminal) can be
168/// asserted on directly in tests, rather than intercepting the log
169/// backend.
170fn 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// ── Tests ─────────────────────────────────────────────────────────────
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::trace::HeaderRedactionMode;
278
279    /// Build a minimal `ParsedRequest` carrying the given headers.
280    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    /// RFC 051 evidence requirement: with `log.verbose.header` on and no
293    /// other configuration (`TraceConfig::default()`), the rendered log
294    /// line contains none of the credential values, but a non-credential
295    /// header's value still appears.
296    #[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    /// Redacted headers stay present, marked with the placeholder — not
325    /// silently dropped from the rendered line (RFC 040 Goal 4, reused
326    /// here per RFC 051).
327    #[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    /// A denylist compared case-sensitively is a leak that passes a naive
340    /// test — proven here with non-lowercase spellings.
341    #[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    /// The policy is shared, not copied: an allowlist configured on the
361    /// same `TraceConfig` passed to the trace channel also governs verbose
362    /// logging, with no separate list to keep in sync.
363    #[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    /// `capture_in_log`'s public, two-argument signature must keep
387    /// compiling for any out-of-tree caller (RFC 051 review, § 2) — a
388    /// regression guard on the API surface itself. Its body has no
389    /// return value to assert redaction on directly; that behaviour is
390    /// `render_request_log`'s, exercised by the tests above with the
391    /// same `TraceConfig::default()` this delegates to.
392    #[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}