provide_telemetry/
schema.rs1use regex::Regex;
7use std::sync::{
8 atomic::{AtomicBool, Ordering},
9 OnceLock,
10};
11
12use crate::errors::EventSchemaError;
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct Event {
16 pub event: String,
17 pub domain: String,
18 pub action: String,
19 pub resource: Option<String>,
20 pub status: String,
21}
22
23static STRICT_SCHEMA: AtomicBool = AtomicBool::new(false);
24
25pub fn set_strict_schema(enabled: bool) {
27 STRICT_SCHEMA.store(enabled, Ordering::Relaxed);
28}
29
30pub fn get_strict_schema() -> bool {
32 STRICT_SCHEMA.load(Ordering::Relaxed)
33}
34
35pub fn _reset_schema_for_tests() {
37 STRICT_SCHEMA.store(false, Ordering::Relaxed);
38}
39
40fn segment_re() -> &'static Regex {
41 static SEGMENT_RE: OnceLock<Regex> = OnceLock::new();
42 SEGMENT_RE.get_or_init(|| Regex::new(r"^[a-z][a-z0-9_]*$").expect("valid regex"))
43}
44
45pub fn event(segments: &[&str]) -> Result<Event, EventSchemaError> {
46 if !(3..=4).contains(&segments.len()) {
47 return Err(EventSchemaError::new(format!(
48 "event() requires 3 or 4 segments (DA[R]S), got {}",
49 segments.len()
50 )));
51 }
52
53 if get_strict_schema() {
54 for (idx, segment) in segments.iter().enumerate() {
55 if !segment_re().is_match(segment) {
56 return Err(EventSchemaError::new(format!(
57 "invalid event segment: segment[{idx}]={segment}"
58 )));
59 }
60 }
61 }
62
63 let event = segments.join(".");
64 Ok(Event {
65 event,
66 domain: segments[0].to_string(),
67 action: segments[1].to_string(),
68 resource: if segments.len() == 4 {
69 Some(segments[2].to_string())
70 } else {
71 None
72 },
73 status: segments[segments.len() - 1].to_string(),
74 })
75}
76
77pub fn event_name(segments: &[&str]) -> Result<String, EventSchemaError> {
78 let strict = get_strict_schema();
79 if strict {
80 if !(3..=5).contains(&segments.len()) {
81 return Err(EventSchemaError::new(format!(
82 "expected 3-5 segments, got {}",
83 segments.len()
84 )));
85 }
86 for (idx, segment) in segments.iter().enumerate() {
87 if !segment_re().is_match(segment) {
88 return Err(EventSchemaError::new(format!(
89 "invalid event segment: segment[{idx}]={segment}"
90 )));
91 }
92 }
93 } else if segments.is_empty() {
94 return Err(EventSchemaError::new(
95 "event_name requires at least 1 segment",
96 ));
97 }
98 Ok(segments.join("."))
99}
100
101pub fn validate_required_keys(
102 data: &std::collections::BTreeMap<String, serde_json::Value>,
103 required_keys: &[String],
104) -> Result<(), EventSchemaError> {
105 let mut missing: Vec<&str> = required_keys
106 .iter()
107 .map(String::as_str)
108 .filter(|key| !data.contains_key(*key))
109 .collect();
110 if missing.is_empty() {
111 return Ok(());
112 }
113 missing.sort_unstable();
114 Err(EventSchemaError::new(format!(
115 "missing required keys: {}",
116 missing.join(", ")
117 )))
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use crate::testing::acquire_test_state_lock;
124
125 #[test]
126 fn schema_test_event_name_returns_exact_joined_value() {
127 let _guard = acquire_test_state_lock();
128 set_strict_schema(false);
129 assert_eq!(
130 event_name(&["auth", "login", "ok"]).expect("name should build"),
131 "auth.login.ok"
132 );
133 set_strict_schema(true);
134 assert_eq!(
135 event_name(&["a", "b", "c", "d", "e"]).expect("strict name should build"),
136 "a.b.c.d.e"
137 );
138 set_strict_schema(false);
139 }
140
141 #[test]
142 fn schema_test_event_strict_gates_segment_format_validation() {
143 let _guard = acquire_test_state_lock();
144 set_strict_schema(false);
146 let ev = event(&["not-valid", "b", "c"]).expect("non-strict should accept invalid segment");
147 assert_eq!(ev.event, "not-valid.b.c");
148
149 set_strict_schema(true);
151 let err =
152 event(&["not-valid", "b", "c"]).expect_err("strict should reject invalid segment");
153 assert!(
154 err.message.contains("invalid event segment"),
155 "unexpected error: {}",
156 err.message
157 );
158 set_strict_schema(false);
159 }
160
161 #[test]
162 fn schema_test_event_name_validates_empty_and_invalid_strict_inputs() {
163 let _guard = acquire_test_state_lock();
164 set_strict_schema(false);
165 let err = event_name(&[]).expect_err("empty non-strict name should fail");
166 assert_eq!(err.message, "event_name requires at least 1 segment");
167
168 set_strict_schema(true);
169 let err = event_name(&["a", "b"]).expect_err("strict arity should fail");
170 assert_eq!(err.message, "expected 3-5 segments, got 2");
171
172 let err = event_name(&["valid", "not-valid", "ok"]).expect_err("strict syntax should fail");
173 assert_eq!(err.message, "invalid event segment: segment[1]=not-valid");
174 set_strict_schema(false);
175 }
176
177 #[test]
178 fn schema_test_validate_required_keys_matches_python_contract() {
179 let mut data = std::collections::BTreeMap::new();
180 data.insert(
181 "domain".to_string(),
182 serde_json::Value::String("auth".to_string()),
183 );
184 validate_required_keys(&data, &["domain".to_string()]).expect("key should exist");
185
186 let err = validate_required_keys(&data, &["domain".to_string(), "action".to_string()])
187 .expect_err("missing key should fail");
188 assert_eq!(err.message, "missing required keys: action");
189 }
190}