use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
const DEFAULT_TTL_SECS: i64 = 300;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct IdempotencyStore {
entries: HashMap<String, IdempotencyEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdempotencyEntry {
pub response: String, pub expires_at: DateTime<Utc>,
}
impl IdempotencyStore {
pub fn new() -> Self {
Self {
entries: HashMap::new(),
}
}
pub fn check(&self, key: &str) -> Option<&str> {
let entry = self.entries.get(key)?;
if Utc::now() < entry.expires_at {
Some(&entry.response)
} else {
None }
}
pub fn record(&mut self, key: String, response: String) {
self.entries.insert(
key,
IdempotencyEntry {
response,
expires_at: Utc::now() + Duration::seconds(DEFAULT_TTL_SECS),
},
);
}
pub fn record_with_ttl(&mut self, key: String, response: String, ttl_secs: i64) {
self.entries.insert(
key,
IdempotencyEntry {
response,
expires_at: Utc::now() + Duration::seconds(ttl_secs),
},
);
}
pub fn gc(&mut self) {
let now = Utc::now();
self.entries.retain(|_, entry| entry.expires_at > now);
}
pub fn len(&self) -> usize {
let now = Utc::now();
self.entries.values().filter(|e| e.expires_at > now).count()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_idempotency_check_and_record() {
let mut store = IdempotencyStore::new();
assert!(store.check("key-1").is_none());
store.record("key-1".to_string(), "response-1".to_string());
assert_eq!(store.check("key-1"), Some("response-1"));
}
#[test]
fn test_idempotency_expiry() {
let mut store = IdempotencyStore::new();
store.record_with_ttl("key-1".to_string(), "response-1".to_string(), -1);
assert!(store.check("key-1").is_none());
}
#[test]
fn test_gc() {
let mut store = IdempotencyStore::new();
store.record("valid".to_string(), "ok".to_string());
store.record_with_ttl("expired".to_string(), "old".to_string(), -1);
store.gc();
assert_eq!(store.entries.len(), 1);
assert!(store.entries.contains_key("valid"));
}
#[test]
fn test_len_excludes_expired() {
let mut store = IdempotencyStore::new();
store.record("valid".to_string(), "ok".to_string());
store.record_with_ttl("expired".to_string(), "old".to_string(), -1);
assert_eq!(store.len(), 1);
}
}