Skip to main content

mockforge_foundation/
unknown_paths.rs

1//! Unmatched-path request tracking.
2//!
3//! Issue #79 round 13 — Srikanth's question (a): when a client sends
4//! requests to paths that aren't in the server's loaded OpenAPI spec,
5//! the request never reaches the validator (router returns 404 from
6//! lookup), so `conformance_violations` never picks it up. This module
7//! captures those unmatched 404s into a separate bounded ring buffer
8//! so the TUI's Conformance tab can surface them.
9//!
10//! Use case: cross-checking a proxy's path coverage against the
11//! server's. If the proxy reports a path the server doesn't know,
12//! it'll show up here.
13
14use chrono::{DateTime, Utc};
15use once_cell::sync::Lazy;
16use parking_lot::Mutex;
17use serde::{Deserialize, Serialize};
18use std::collections::VecDeque;
19
20/// Issue #79 round 14 — Srikanth's shadow-mode ask. When enabled, the
21/// server returns `200` for requests that would otherwise be rejected
22/// (unknown paths → 404, spec violations → 400/422) while still
23/// recording them to the unknown-paths / conformance buffers. Lets a
24/// proxy replay run flow through non-blocking with full violation
25/// capture — a "report-only" / monitor mode.
26///
27/// Read once per request from `MOCKFORGE_SHADOW_MODE` (`1`/`true`).
28/// Cheap enough for the hot path; no caching needed since env lookups
29/// are fast and this keeps the flag dynamically toggleable in tests.
30pub fn shadow_mode_enabled() -> bool {
31    std::env::var("MOCKFORGE_SHADOW_MODE")
32        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
33        .unwrap_or(false)
34}
35
36/// One unmatched-path request — captured by the HTTP server's fallback
37/// when no registered route matches.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct UnknownPathRequest {
40    /// When the request was rejected.
41    pub timestamp: DateTime<Utc>,
42    /// HTTP method (uppercase).
43    pub method: String,
44    /// Raw request path (not normalised to any spec template).
45    pub path: String,
46    /// Client IP if available, else `"unknown"`.
47    pub client_ip: String,
48    /// Query string portion, if any.
49    pub query: String,
50    /// HTTP status the server actually returned for this request.
51    /// Normally `404`; in shadow mode (Issue #79 round 14) the server
52    /// returns `200` instead but still records the unknown path here,
53    /// so the column reflects what the client saw.
54    #[serde(default = "default_unknown_status")]
55    pub status: u16,
56}
57
58fn default_unknown_status() -> u16 {
59    404
60}
61
62const DEFAULT_BUFFER_SIZE: usize = 256;
63
64static UNKNOWN_PATHS: Lazy<Mutex<VecDeque<UnknownPathRequest>>> =
65    Lazy::new(|| Mutex::new(VecDeque::with_capacity(DEFAULT_BUFFER_SIZE)));
66
67/// Record an unmatched-path 404. FIFO when the buffer is full.
68pub fn record(req: UnknownPathRequest) {
69    let mut buf = UNKNOWN_PATHS.lock();
70    if buf.len() == DEFAULT_BUFFER_SIZE {
71        buf.pop_front();
72    }
73    buf.push_back(req);
74}
75
76/// Snapshot of buffered entries, newest first.
77pub fn snapshot() -> Vec<UnknownPathRequest> {
78    let buf = UNKNOWN_PATHS.lock();
79    buf.iter().rev().cloned().collect()
80}
81
82/// Current buffer length.
83pub fn len() -> usize {
84    UNKNOWN_PATHS.lock().len()
85}
86
87/// Clear the buffer.
88pub fn clear() {
89    UNKNOWN_PATHS.lock().clear();
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    /// The buffer tests mutate the global `UNKNOWN_PATHS` static, so
97    /// they must not interleave (cargo runs tests in parallel by
98    /// default). Serialize them through a shared lock.
99    static TEST_LOCK: Mutex<()> = Mutex::new(());
100
101    fn req(path: &str) -> UnknownPathRequest {
102        UnknownPathRequest {
103            timestamp: Utc::now(),
104            method: "GET".into(),
105            path: path.into(),
106            client_ip: "127.0.0.1".into(),
107            query: String::new(),
108            status: 404,
109        }
110    }
111
112    #[test]
113    fn record_and_snapshot_lifo() {
114        let _guard = TEST_LOCK.lock();
115        clear();
116        record(req("/first"));
117        record(req("/second"));
118        let snap = snapshot();
119        assert_eq!(snap.len(), 2);
120        assert_eq!(snap[0].path, "/second");
121        assert_eq!(snap[1].path, "/first");
122    }
123
124    #[test]
125    fn drops_oldest_at_capacity() {
126        let _guard = TEST_LOCK.lock();
127        clear();
128        for i in 0..(DEFAULT_BUFFER_SIZE + 5) {
129            record(req(&format!("/p/{i}")));
130        }
131        assert_eq!(len(), DEFAULT_BUFFER_SIZE);
132        let snap = snapshot();
133        assert_eq!(snap[0].path, format!("/p/{}", DEFAULT_BUFFER_SIZE + 4));
134        assert_eq!(snap[DEFAULT_BUFFER_SIZE - 1].path, "/p/5");
135    }
136
137    #[test]
138    fn shadow_mode_reads_env() {
139        std::env::set_var("MOCKFORGE_SHADOW_MODE", "true");
140        assert!(shadow_mode_enabled());
141        std::env::set_var("MOCKFORGE_SHADOW_MODE", "1");
142        assert!(shadow_mode_enabled());
143        std::env::set_var("MOCKFORGE_SHADOW_MODE", "0");
144        assert!(!shadow_mode_enabled());
145        std::env::remove_var("MOCKFORGE_SHADOW_MODE");
146        assert!(!shadow_mode_enabled());
147    }
148
149    #[test]
150    fn status_defaults_to_404_on_legacy_payload() {
151        // Old payloads (round 13) had no `status` field; serde default
152        // must fill 404 so the TUI column doesn't break on older servers.
153        let json = r#"{"timestamp":"2026-05-26T00:00:00Z","method":"GET","path":"/x","client_ip":"unknown","query":""}"#;
154        let parsed: UnknownPathRequest = serde_json::from_str(json).unwrap();
155        assert_eq!(parsed.status, 404);
156    }
157}