use crate::charge::Charge;
use crate::transfer::Transfer;
use rustlavel_core::{Json, Result};
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Mutex;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EventKind {
ChargePaid,
ChargeExpired,
ChargeFailed,
TransferCompleted,
TransferFailed,
Other(String),
}
impl EventKind {
pub fn as_str(&self) -> &str {
match self {
EventKind::ChargePaid => "charge.paid",
EventKind::ChargeExpired => "charge.expired",
EventKind::ChargeFailed => "charge.failed",
EventKind::TransferCompleted => "transfer.completed",
EventKind::TransferFailed => "transfer.failed",
EventKind::Other(name) => name,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct WebhookEvent {
pub id: String,
pub kind: EventKind,
pub charge: Option<Charge>,
pub transfer: Option<Transfer>,
pub raw: Json,
}
impl WebhookEvent {
pub fn dedup_key(&self) -> String {
format!("{}:{}", self.kind.as_str(), self.id)
}
}
pub type LogFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T>> + Send + 'a>>;
pub trait WebhookLog: Send + Sync + 'static {
fn record<'a>(&'a self, event: &'a WebhookEvent) -> LogFuture<'a, bool>;
fn raw<'a>(&'a self, dedup_key: &'a str) -> LogFuture<'a, Option<Json>>;
fn withdraw<'a>(&'a self, dedup_key: &'a str) -> LogFuture<'a, ()>;
}
#[derive(Default)]
pub struct MemoryWebhookLog {
seen: Mutex<HashMap<String, Json>>,
}
impl MemoryWebhookLog {
pub fn new() -> MemoryWebhookLog {
MemoryWebhookLog::default()
}
pub fn len(&self) -> usize {
self.seen.lock().expect("the webhook log is poisoned").len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl WebhookLog for MemoryWebhookLog {
fn record<'a>(&'a self, event: &'a WebhookEvent) -> LogFuture<'a, bool> {
Box::pin(async move {
let mut seen = self.seen.lock().expect("the webhook log is poisoned");
if seen.contains_key(&event.dedup_key()) {
return Ok(false);
}
seen.insert(event.dedup_key(), event.raw.clone());
Ok(true)
})
}
fn raw<'a>(&'a self, dedup_key: &'a str) -> LogFuture<'a, Option<Json>> {
Box::pin(async move {
Ok(self.seen.lock().expect("the webhook log is poisoned").get(dedup_key).cloned())
})
}
fn withdraw<'a>(&'a self, dedup_key: &'a str) -> LogFuture<'a, ()> {
Box::pin(async move {
self.seen.lock().expect("the webhook log is poisoned").remove(dedup_key);
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn event(id: &str, kind: EventKind) -> WebhookEvent {
WebhookEvent { id: id.into(), kind, charge: None, transfer: None, raw: Json::from(id) }
}
#[tokio::test]
async fn the_same_event_is_recorded_once() {
let log = MemoryWebhookLog::new();
assert!(log.record(&event("ch_1", EventKind::ChargePaid)).await.unwrap());
assert!(!log.record(&event("ch_1", EventKind::ChargePaid)).await.unwrap(), "a duplicate was accepted");
assert_eq!(log.len(), 1);
}
#[tokio::test]
async fn the_same_id_under_another_kind_is_another_event() {
let log = MemoryWebhookLog::new();
assert!(log.record(&event("ch_1", EventKind::ChargePaid)).await.unwrap());
assert!(log.record(&event("ch_1", EventKind::ChargeExpired)).await.unwrap());
}
#[tokio::test]
async fn a_withdrawn_event_can_be_recorded_again() {
let log = MemoryWebhookLog::new();
let e = event("ch_1", EventKind::ChargePaid);
assert!(log.record(&e).await.unwrap());
log.withdraw(&e.dedup_key()).await.unwrap();
assert!(log.record(&e).await.unwrap(), "the retry was dropped as a duplicate");
}
#[tokio::test]
async fn the_raw_body_is_kept_for_replay() {
let log = MemoryWebhookLog::new();
let e = event("ch_9", EventKind::ChargePaid);
log.record(&e).await.unwrap();
assert_eq!(log.raw(&e.dedup_key()).await.unwrap(), Some(Json::from("ch_9")));
assert_eq!(log.raw("charge.paid:nothing").await.unwrap(), None);
}
}