Skip to main content

mockforge_foundation/
conformance_violations.rs

1//! Server-side conformance violation tracking.
2//!
3//! Issue #79 round 12 — Srikanth's ask: "It would be good if mockforge tui
4//! have a separate section for conformance failures on the incoming
5//! requests to the mockforge server which has spec violation from the
6//! Server Side point of view, that way I can cross check Server Side
7//! Info with our proxy and understand the diff."
8//!
9//! The OpenAPI router already rejects requests that violate the loaded
10//! spec (status 400/422). This module captures every such rejection into
11//! a bounded ring buffer so the TUI / admin API can surface them
12//! without scraping logs.
13//!
14//! Storage is best-effort, in-memory, and bounded — under sustained
15//! WAF / load-test traffic we keep only the most recent N violations.
16
17use chrono::{DateTime, Utc};
18use once_cell::sync::Lazy;
19use parking_lot::Mutex;
20use serde::{Deserialize, Serialize};
21use std::collections::VecDeque;
22use std::sync::atomic::{AtomicU64, Ordering};
23
24/// A single server-side conformance violation captured at the OpenAPI
25/// router. Mirrors `ConformanceViolation` semantics from the bench-side
26/// client validator so consumers can use the same dashboards.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ServerConformanceViolation {
29    /// When the request was rejected.
30    pub timestamp: DateTime<Utc>,
31    /// HTTP method (uppercase).
32    pub method: String,
33    /// Spec-template path the request matched (e.g. `/users/{id}`).
34    pub path: String,
35    /// Client IP if available, else `"unknown"`.
36    pub client_ip: String,
37    /// HTTP status the server replied with (typically 400 or 422).
38    pub status: u16,
39    /// Short, human-readable reason — derived from the validator error.
40    pub reason: String,
41    /// Spec category the violation falls into (`"parameters"`,
42    /// `"request-body"`, `"headers"`, etc.). Empty if the validator
43    /// couldn't classify.
44    pub category: String,
45}
46
47const DEFAULT_BUFFER_SIZE: usize = 256;
48
49static VIOLATIONS: Lazy<Mutex<VecDeque<ServerConformanceViolation>>> =
50    Lazy::new(|| Mutex::new(VecDeque::with_capacity(DEFAULT_BUFFER_SIZE)));
51
52/// Lifetime count of violations recorded since process start (Issue #79
53/// round 15). The ring buffer only keeps the most recent
54/// `DEFAULT_BUFFER_SIZE`; this counter answers Srikanth's "I sent 656k
55/// requests but only see 256" — the 256 is the buffer cap, this is the
56/// true total seen.
57static TOTAL_SEEN: AtomicU64 = AtomicU64::new(0);
58
59/// Lifetime count of requests that *passed* the spec validator (round
60/// 17.1). Bumped on the `Ok(())` branch of
61/// `run_validation_with_recording`. Lets the TUI display
62/// "X conformant / Y violations" instead of just one side.
63static TOTAL_OK: AtomicU64 = AtomicU64::new(0);
64
65/// Bump the conformant-request counter. Called from the validator's
66/// success path. Bench code can call it directly when wiring its own
67/// counters too.
68pub fn record_ok() {
69    TOTAL_OK.fetch_add(1, Ordering::Relaxed);
70}
71
72/// Lifetime total of requests that passed the spec validator.
73pub fn total_ok() -> u64 {
74    TOTAL_OK.load(Ordering::Relaxed)
75}
76
77/// Record a violation. Old entries are dropped when the buffer is full
78/// (FIFO). Cheap enough to call from the hot path — uses a parking_lot
79/// Mutex which is uncontended in steady state.
80pub fn record(violation: ServerConformanceViolation) {
81    TOTAL_SEEN.fetch_add(1, Ordering::Relaxed);
82    let mut buf = VIOLATIONS.lock();
83    if buf.len() == DEFAULT_BUFFER_SIZE {
84        buf.pop_front();
85    }
86    buf.push_back(violation);
87}
88
89/// Snapshot of the buffered violations, newest first.
90pub fn snapshot() -> Vec<ServerConformanceViolation> {
91    let buf = VIOLATIONS.lock();
92    buf.iter().rev().cloned().collect()
93}
94
95/// Number of violations currently buffered (≤ `DEFAULT_BUFFER_SIZE`).
96pub fn len() -> usize {
97    VIOLATIONS.lock().len()
98}
99
100/// Lifetime total of violations recorded since process start, including
101/// ones the ring buffer has since evicted.
102pub fn total_seen() -> u64 {
103    TOTAL_SEEN.load(Ordering::Relaxed)
104}
105
106/// Clear the buffer and reset both lifetime counters. Primarily for
107/// tests and TUI "reset" actions.
108pub fn clear() {
109    VIOLATIONS.lock().clear();
110    TOTAL_SEEN.store(0, Ordering::Relaxed);
111    TOTAL_OK.store(0, Ordering::Relaxed);
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    fn v(method: &str, status: u16) -> ServerConformanceViolation {
119        ServerConformanceViolation {
120            timestamp: Utc::now(),
121            method: method.to_string(),
122            path: "/test".into(),
123            client_ip: "127.0.0.1".into(),
124            status,
125            reason: "test".into(),
126            category: "parameters".into(),
127        }
128    }
129
130    #[test]
131    fn record_and_snapshot_in_lifo_order() {
132        clear();
133        record(v("GET", 400));
134        record(v("POST", 422));
135        let snap = snapshot();
136        assert_eq!(snap.len(), 2);
137        // newest first
138        assert_eq!(snap[0].method, "POST");
139        assert_eq!(snap[1].method, "GET");
140    }
141
142    #[test]
143    fn buffer_drops_oldest_at_capacity() {
144        clear();
145        for i in 0..(DEFAULT_BUFFER_SIZE + 50) {
146            let mut entry = v("GET", 400);
147            entry.reason = format!("{i}");
148            record(entry);
149        }
150        assert_eq!(len(), DEFAULT_BUFFER_SIZE);
151        let snap = snapshot();
152        // newest is the last one we pushed
153        assert_eq!(snap[0].reason, format!("{}", DEFAULT_BUFFER_SIZE + 50 - 1));
154        // oldest still present is index 50 (the first 50 got dropped)
155        assert_eq!(snap[DEFAULT_BUFFER_SIZE - 1].reason, format!("{}", 50));
156    }
157}