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