apimock-server 6.2.0

HTTP(S) server runtime for apimock: listener loop, request handling, response building.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
//! Server-side helpers for `apimock_routing::ParsedRequest`.
//!
//! # Why this file exists after the 5.0 split
//!
//! `ParsedRequest` (the data) now lives in `apimock-routing` so the
//! matcher crate can depend on it without pulling in hyper/body I/O.
//! The two operations that *touch* HTTP — building a `ParsedRequest`
//! from an incoming hyper request, and logging one to stdout — are
//! server-layer activities, so they stay here as free functions.

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};

/// Failure building a `ParsedRequest` from an incoming request.
///
/// Split out from a bare `String` (RFC 068 S-02) so the caller can
/// answer **413** for an oversized body specifically, rather than the
/// generic 500 every other failure here gets — the client's mistake,
/// not the server's.
#[derive(Debug)]
#[non_exhaustive]
pub enum ParsedRequestError {
    /// The body exceeded `max_body_bytes`.
    BodyTooLarge,
    /// Any other failure — unchanged from this function's previous
    /// bare-`String` error.
    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),
        }
    }
}

/// Consume an incoming hyper request into a `ParsedRequest` the matcher
/// can use.
///
/// # Why a non-JSON body is logged but not rejected
///
/// Some rule sets key only on URL path or headers and don't inspect the
/// body at all. Failing the whole request because an operator sent a
/// form-encoded payload would be more aggressive than needed; we log a
/// warning and continue so the URL-path-only rules still apply. Only
/// *claimed* JSON (`Content-Type: application/json`) that fails to
/// parse becomes a hard `Err` — that is a real client bug.
///
/// # `max_body_bytes` (RFC 068 S-02)
///
/// The body used to be collected whole with no cap — the external
/// audit measured one 256 MiB request taking the process from 9 MiB
/// RSS to 462 MiB. `http_body_util::Limited` bounds how much of the
/// body is ever buffered to (at most, just past) `max_body_bytes`
/// before this returns `Err(BodyTooLarge)` instead of continuing to
/// read — the caller answers 413 without the body ever being fully
/// collected.
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);

        // RFC 077 P-09: hoisted so the header lookup happens once per
        // request instead of once per match arm below.
        let content_type_is_application_json =
            content_type_is_application_json(&component_parts.headers);

        match (content_type_is_application_json, raw_body_json) {
            // declared application/json but body didn't parse → hard error
            (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
    };

    // RFC 075 F-03: percent-decode before normalising, never after —
    // see `percent_decode_url_path`'s own doc comment for why the order
    // is security-critical, not stylistic.
    let decoded_url_path = percent_decode_url_path(component_parts.uri.path());
    let url_path = normalize_url_path(&decoded_url_path, None);

    // RFC 050: propagate what's already been measured above (`has_body`,
    // `body_bytes`'s length) rather than computing anything new.
    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))
}

/// Emit a single log line describing the request.
///
/// Kept as a public, two-argument function so any caller outside this
/// workspace keeps compiling unchanged (RFC 051 review, R-09 applies to
/// function signatures on `pub fn`s, not only to struct fields). It
/// still redacts — `TraceConfig::default()` carries the default
/// denylist — so an out-of-tree caller gets the security fix for free
/// rather than needing to opt in. In-workspace, use
/// `capture_in_log_with_trace_config` (crate-private by design — not
/// linked here since it isn't part of this crate's public surface),
/// which shares the server's own `TraceConfig` instead of a fresh
/// default one.
pub fn capture_in_log(request: &ParsedRequest, verbose: VerboseConfig) {
    capture_in_log_with_trace_config(request, verbose, &TraceConfig::default())
}

/// [`capture_in_log`], but redacting per `trace_config` instead of a
/// fresh `TraceConfig::default()` — the one place this and the trace
/// channel (`crate::trace::redact_headers`) can never honour two
/// different denylists, because both read the same `TraceConfig`
/// instance the running server built (RFC 051).
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));
}

/// Build the line `capture_in_log` emits, without emitting it — split out
/// so the rendered text (what actually reaches a terminal) can be
/// asserted on directly in tests, rather than intercepting the log
/// backend.
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 {
        // RFC 073 S-05: query strings and bodies used to print raw,
        // unredacted, here — the same denylist/allowlist that already
        // covers headers above (`trace_config.is_redacted_key`) now
        // covers these too, so a credential doesn't reach the console
        // just because it travelled in the query string or body
        // instead of a header.
        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
}

// ── Tests ─────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::trace::HeaderRedactionMode;

    /// Build a minimal `ParsedRequest` carrying the given headers.
    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);

    /// RFC 051 evidence requirement: with `log.verbose.header` on and no
    /// other configuration (`TraceConfig::default()`), the rendered log
    /// line contains none of the credential values, but a non-credential
    /// header's value still appears.
    #[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}"
        );
    }

    /// Redacted headers stay present, marked with the placeholder — not
    /// silently dropped from the rendered line (RFC 040 Goal 4, reused
    /// here per RFC 051).
    #[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}"
        );
    }

    /// A denylist compared case-sensitively is a leak that passes a naive
    /// test — proven here with non-lowercase spellings.
    #[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}"
        );
    }

    /// The policy is shared, not copied: an allowlist configured on the
    /// same `TraceConfig` passed to the trace channel also governs verbose
    /// logging, with no separate list to keep in sync.
    #[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}"
        );
    }

    /// `capture_in_log`'s public, two-argument signature must keep
    /// compiling for any out-of-tree caller (RFC 051 review, § 2) — a
    /// regression guard on the API surface itself. Its body has no
    /// return value to assert redaction on directly; that behaviour is
    /// `render_request_log`'s, exercised by the tests above with the
    /// same `TraceConfig::default()` this delegates to.
    #[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);
    }

    // ── RFC 073 S-05: query-string and body redaction in verbose output ──

    const VERBOSE_BODY_ONLY: VerboseConfig = VerboseConfig::new(false, true);

    /// Build a `ParsedRequest` with a query string, no body.
    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)
    }

    /// The tranche 5 handoff's own acceptance example, at the console
    /// log: a `?token=secret` query parameter must not appear verbatim
    /// under `log.verbose.body`'s default (redacted) settings — the
    /// same denylist that already redacts headers above now covers the
    /// query string too (RFC 073 S-05).
    #[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}"
        );
    }

    /// The handoff's other acceptance example: a secret in the JSON
    /// body must not appear verbatim under default settings either.
    #[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}"
        );
    }
}