Skip to main content

slack_rs/idempotency/
types.rs

1//! Types for idempotency tracking
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7/// Idempotency status
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9#[serde(rename_all = "lowercase")]
10pub enum IdempotencyStatus {
11    /// Operation was executed
12    Executed,
13    /// Operation was replayed from cache
14    Replayed,
15}
16
17/// Scoped idempotency key
18///
19/// Format: team_id/user_id/method/idempotency_key
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
21pub struct ScopedKey {
22    pub team_id: String,
23    pub user_id: String,
24    pub method: String,
25    pub idempotency_key: String,
26}
27
28impl ScopedKey {
29    /// Create a new scoped key
30    pub fn new(team_id: String, user_id: String, method: String, idempotency_key: String) -> Self {
31        Self {
32            team_id,
33            user_id,
34            method,
35            idempotency_key,
36        }
37    }
38}
39
40impl std::fmt::Display for ScopedKey {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        write!(
43            f,
44            "{}/{}/{}/{}",
45            self.team_id, self.user_id, self.method, self.idempotency_key
46        )
47    }
48}
49
50/// Request fingerprint for duplicate detection
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52pub struct RequestFingerprint {
53    /// SHA-256 hash of normalized request parameters
54    pub hash: String,
55}
56
57impl RequestFingerprint {
58    /// Create fingerprint from request parameters
59    pub fn from_params(params: &serde_json::Map<String, Value>) -> Self {
60        use sha2::{Digest, Sha256};
61
62        // Create a sorted JSON string for stable hashing
63        let mut sorted_params: Vec<_> = params.iter().collect();
64        sorted_params.sort_by_key(|(k, _)| *k);
65
66        let mut hasher = Sha256::new();
67        for (key, value) in sorted_params {
68            hasher.update(key.as_bytes());
69            hasher.update(b":");
70            // Serialize value to stable JSON string
71            let value_str = serde_json::to_string(value).unwrap_or_default();
72            hasher.update(value_str.as_bytes());
73            hasher.update(b";");
74        }
75
76        let result = hasher.finalize();
77        Self {
78            hash: format!("{:x}", result),
79        }
80    }
81}
82
83/// Idempotency entry stored in the cache
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct IdempotencyEntry {
86    /// Request fingerprint
87    pub fingerprint: RequestFingerprint,
88
89    /// Stored response
90    pub response: Value,
91
92    /// Creation timestamp (Unix epoch seconds)
93    pub created_at: u64,
94
95    /// Expiration timestamp (Unix epoch seconds)
96    pub expires_at: u64,
97}
98
99impl IdempotencyEntry {
100    /// Create a new entry with TTL in seconds
101    pub fn new(fingerprint: RequestFingerprint, response: Value, ttl_seconds: u64) -> Self {
102        let now = SystemTime::now()
103            .duration_since(UNIX_EPOCH)
104            .unwrap()
105            .as_secs();
106
107        Self {
108            fingerprint,
109            response,
110            created_at: now,
111            expires_at: now + ttl_seconds,
112        }
113    }
114
115    /// Check if entry is expired
116    pub fn is_expired(&self) -> bool {
117        let now = SystemTime::now()
118            .duration_since(UNIX_EPOCH)
119            .unwrap()
120            .as_secs();
121        now > self.expires_at
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use serde_json::json;
129
130    #[test]
131    fn test_scoped_key_creation() {
132        let key = ScopedKey::new(
133            "T123".into(),
134            "U456".into(),
135            "chat.postMessage".into(),
136            "my-key".into(),
137        );
138
139        assert_eq!(key.team_id, "T123");
140        assert_eq!(key.user_id, "U456");
141        assert_eq!(key.method, "chat.postMessage");
142        assert_eq!(key.idempotency_key, "my-key");
143    }
144
145    #[test]
146    fn test_scoped_key_to_string() {
147        let key = ScopedKey::new(
148            "T123".into(),
149            "U456".into(),
150            "chat.postMessage".into(),
151            "my-key".into(),
152        );
153
154        assert_eq!(key.to_string(), "T123/U456/chat.postMessage/my-key");
155    }
156
157    #[test]
158    fn test_fingerprint_same_params() {
159        let mut params1 = serde_json::Map::new();
160        params1.insert("channel".into(), json!("C123"));
161        params1.insert("text".into(), json!("hello"));
162
163        let mut params2 = serde_json::Map::new();
164        params2.insert("channel".into(), json!("C123"));
165        params2.insert("text".into(), json!("hello"));
166
167        let fp1 = RequestFingerprint::from_params(&params1);
168        let fp2 = RequestFingerprint::from_params(&params2);
169
170        assert_eq!(fp1.hash, fp2.hash);
171    }
172
173    #[test]
174    fn test_fingerprint_different_params() {
175        let mut params1 = serde_json::Map::new();
176        params1.insert("channel".into(), json!("C123"));
177        params1.insert("text".into(), json!("hello"));
178
179        let mut params2 = serde_json::Map::new();
180        params2.insert("channel".into(), json!("C123"));
181        params2.insert("text".into(), json!("goodbye"));
182
183        let fp1 = RequestFingerprint::from_params(&params1);
184        let fp2 = RequestFingerprint::from_params(&params2);
185
186        assert_ne!(fp1.hash, fp2.hash);
187    }
188
189    #[test]
190    fn test_fingerprint_order_independence() {
191        let mut params1 = serde_json::Map::new();
192        params1.insert("channel".into(), json!("C123"));
193        params1.insert("text".into(), json!("hello"));
194        params1.insert("thread_ts".into(), json!("1234567890.123456"));
195
196        let mut params2 = serde_json::Map::new();
197        params2.insert("text".into(), json!("hello"));
198        params2.insert("thread_ts".into(), json!("1234567890.123456"));
199        params2.insert("channel".into(), json!("C123"));
200
201        let fp1 = RequestFingerprint::from_params(&params1);
202        let fp2 = RequestFingerprint::from_params(&params2);
203
204        // Should be the same regardless of insertion order
205        assert_eq!(fp1.hash, fp2.hash);
206    }
207
208    #[test]
209    fn test_entry_expiration() {
210        let mut params = serde_json::Map::new();
211        params.insert("test".into(), json!("value"));
212        let fingerprint = RequestFingerprint::from_params(&params);
213
214        let response = json!({"ok": true});
215
216        // Entry with 1 second TTL
217        let entry = IdempotencyEntry::new(fingerprint, response, 1);
218        assert!(!entry.is_expired());
219
220        // Wait 2 seconds
221        std::thread::sleep(std::time::Duration::from_secs(2));
222        assert!(entry.is_expired());
223    }
224}