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;
22
23/// A single server-side conformance violation captured at the OpenAPI
24/// router. Mirrors `ConformanceViolation` semantics from the bench-side
25/// client validator so consumers can use the same dashboards.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ServerConformanceViolation {
28    /// When the request was rejected.
29    pub timestamp: DateTime<Utc>,
30    /// HTTP method (uppercase).
31    pub method: String,
32    /// Spec-template path the request matched (e.g. `/users/{id}`).
33    pub path: String,
34    /// Client IP if available, else `"unknown"`.
35    pub client_ip: String,
36    /// HTTP status the server replied with (typically 400 or 422).
37    pub status: u16,
38    /// Short, human-readable reason — derived from the validator error.
39    pub reason: String,
40    /// Spec category the violation falls into (`"parameters"`,
41    /// `"request-body"`, `"headers"`, etc.). Empty if the validator
42    /// couldn't classify.
43    pub category: String,
44}
45
46const DEFAULT_BUFFER_SIZE: usize = 256;
47
48static VIOLATIONS: Lazy<Mutex<VecDeque<ServerConformanceViolation>>> =
49    Lazy::new(|| Mutex::new(VecDeque::with_capacity(DEFAULT_BUFFER_SIZE)));
50
51/// Record a violation. Old entries are dropped when the buffer is full
52/// (FIFO). Cheap enough to call from the hot path — uses a parking_lot
53/// Mutex which is uncontended in steady state.
54pub fn record(violation: ServerConformanceViolation) {
55    let mut buf = VIOLATIONS.lock();
56    if buf.len() == DEFAULT_BUFFER_SIZE {
57        buf.pop_front();
58    }
59    buf.push_back(violation);
60}
61
62/// Snapshot of the buffered violations, newest first.
63pub fn snapshot() -> Vec<ServerConformanceViolation> {
64    let buf = VIOLATIONS.lock();
65    buf.iter().rev().cloned().collect()
66}
67
68/// Number of violations currently buffered.
69pub fn len() -> usize {
70    VIOLATIONS.lock().len()
71}
72
73/// Clear the buffer. Primarily for tests and TUI "reset" actions.
74pub fn clear() {
75    VIOLATIONS.lock().clear();
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    fn v(method: &str, status: u16) -> ServerConformanceViolation {
83        ServerConformanceViolation {
84            timestamp: Utc::now(),
85            method: method.to_string(),
86            path: "/test".into(),
87            client_ip: "127.0.0.1".into(),
88            status,
89            reason: "test".into(),
90            category: "parameters".into(),
91        }
92    }
93
94    #[test]
95    fn record_and_snapshot_in_lifo_order() {
96        clear();
97        record(v("GET", 400));
98        record(v("POST", 422));
99        let snap = snapshot();
100        assert_eq!(snap.len(), 2);
101        // newest first
102        assert_eq!(snap[0].method, "POST");
103        assert_eq!(snap[1].method, "GET");
104    }
105
106    #[test]
107    fn buffer_drops_oldest_at_capacity() {
108        clear();
109        for i in 0..(DEFAULT_BUFFER_SIZE + 50) {
110            let mut entry = v("GET", 400);
111            entry.reason = format!("{i}");
112            record(entry);
113        }
114        assert_eq!(len(), DEFAULT_BUFFER_SIZE);
115        let snap = snapshot();
116        // newest is the last one we pushed
117        assert_eq!(snap[0].reason, format!("{}", DEFAULT_BUFFER_SIZE + 50 - 1));
118        // oldest still present is index 50 (the first 50 got dropped)
119        assert_eq!(snap[DEFAULT_BUFFER_SIZE - 1].reason, format!("{}", 50));
120    }
121}