open-standards/openpay/crates/op-webhook/src/store.rs
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
Transaction Science open standards monorepo. Each subdirectory is a
self-contained reference protocol with its own README and licence.

- openpay/    Apache-2.0  payment-acceptance stack (Rust, 26 crates)
- smart-byte/ CC-BY-4.0   content-addressed value carrier (spec)
- eoc/        CC-BY-4.0   energy-optimised compute (spec)

The wire format and the right to fork are public. The optional hosted
services are the offer; the protocols are not.
2026-05-23 07:58:09 -04:00

381 lines
12 KiB
Rust

//! Pluggable storage for webhook state.
//!
//! Mirrors the pattern used in `op-ledger` and `op-orchestrator`:
//! a trait surface that admits Postgres / Redis / etc. backends,
//! with an in-memory reference impl for tests.
use std::collections::HashMap;
use std::sync::Mutex;
use crate::endpoint::{Endpoint, EndpointId, EndpointStatus};
use crate::error::{Error, Result};
use crate::event::{
DeliveryAttempt, DeliveryAttemptId, DeliveryStatus, WebhookEvent, WebhookEventId,
};
/// Pluggable storage trait. Sync.
pub trait WebhookStore: Send + Sync {
/// Register or update an endpoint.
fn put_endpoint(&self, endpoint: Endpoint) -> Result<EndpointId>;
/// Look up an endpoint.
fn get_endpoint(&self, id: EndpointId) -> Result<Endpoint>;
/// List all endpoints that subscribe to the given event type.
/// Filters to `Active` endpoints only.
fn list_active_endpoints_for(&self, event_type: &str) -> Result<Vec<Endpoint>>;
/// Update endpoint status (used on auto-disable and operator
/// re-enable flows).
fn set_endpoint_status(&self, id: EndpointId, status: EndpointStatus) -> Result<()>;
/// Update an endpoint's `consecutive_failures` counter.
fn set_endpoint_consecutive_failures(&self, id: EndpointId, failures: u32) -> Result<()>;
/// Save an event for replay and audit.
fn put_event(&self, event: WebhookEvent) -> Result<WebhookEventId>;
/// Look up an event.
fn get_event(&self, id: WebhookEventId) -> Result<WebhookEvent>;
/// Save a delivery attempt.
fn put_attempt(&self, attempt: DeliveryAttempt) -> Result<DeliveryAttemptId>;
/// Look up a delivery attempt.
fn get_attempt(&self, id: DeliveryAttemptId) -> Result<DeliveryAttempt>;
/// List attempts for an (event, endpoint) pair in attempt-order.
fn list_attempts(
&self,
event_id: WebhookEventId,
endpoint_id: EndpointId,
) -> Result<Vec<DeliveryAttempt>>;
/// List attempts in [`DeliveryStatus::RetryScheduled`] whose
/// `next_attempt_at_unix_secs <= now`. Used by the
/// dispatcher's `process_due_retries` worker.
fn list_due_retries(&self, now_unix_secs: u64) -> Result<Vec<DeliveryAttempt>>;
}
// ============================================================
// InMemoryWebhookStore
// ============================================================
#[derive(Default)]
struct Inner {
endpoints: HashMap<EndpointId, Endpoint>,
events: HashMap<WebhookEventId, WebhookEvent>,
attempts: HashMap<DeliveryAttemptId, DeliveryAttempt>,
}
/// In-process store. NOT for multi-instance production.
#[derive(Default)]
pub struct InMemoryWebhookStore {
inner: Mutex<Inner>,
}
impl InMemoryWebhookStore {
/// Construct empty.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Diagnostic: number of endpoints.
#[must_use]
pub fn endpoint_count(&self) -> usize {
self.inner.lock().expect("poisoned").endpoints.len()
}
/// Diagnostic: number of events.
#[must_use]
pub fn event_count(&self) -> usize {
self.inner.lock().expect("poisoned").events.len()
}
/// Diagnostic: number of delivery attempts.
#[must_use]
pub fn attempt_count(&self) -> usize {
self.inner.lock().expect("poisoned").attempts.len()
}
}
impl WebhookStore for InMemoryWebhookStore {
fn put_endpoint(&self, endpoint: Endpoint) -> Result<EndpointId> {
let mut g = self.inner.lock().expect("poisoned");
let id = endpoint.id;
g.endpoints.insert(id, endpoint);
Ok(id)
}
fn get_endpoint(&self, id: EndpointId) -> Result<Endpoint> {
let g = self.inner.lock().expect("poisoned");
g.endpoints
.get(&id)
.cloned()
.ok_or_else(|| Error::EndpointNotFound(id.to_string()))
}
fn list_active_endpoints_for(&self, event_type: &str) -> Result<Vec<Endpoint>> {
let g = self.inner.lock().expect("poisoned");
Ok(g.endpoints
.values()
.filter(|e| e.status == EndpointStatus::Active && e.matches(event_type))
.cloned()
.collect())
}
fn set_endpoint_status(&self, id: EndpointId, status: EndpointStatus) -> Result<()> {
let mut g = self.inner.lock().expect("poisoned");
let e = g
.endpoints
.get_mut(&id)
.ok_or_else(|| Error::EndpointNotFound(id.to_string()))?;
e.status = status;
Ok(())
}
fn set_endpoint_consecutive_failures(&self, id: EndpointId, failures: u32) -> Result<()> {
let mut g = self.inner.lock().expect("poisoned");
let e = g
.endpoints
.get_mut(&id)
.ok_or_else(|| Error::EndpointNotFound(id.to_string()))?;
e.consecutive_failures = failures;
Ok(())
}
fn put_event(&self, event: WebhookEvent) -> Result<WebhookEventId> {
let mut g = self.inner.lock().expect("poisoned");
let id = event.id;
g.events.insert(id, event);
Ok(id)
}
fn get_event(&self, id: WebhookEventId) -> Result<WebhookEvent> {
let g = self.inner.lock().expect("poisoned");
g.events
.get(&id)
.cloned()
.ok_or_else(|| Error::EventNotFound(id.to_string()))
}
fn put_attempt(&self, attempt: DeliveryAttempt) -> Result<DeliveryAttemptId> {
let mut g = self.inner.lock().expect("poisoned");
let id = attempt.id;
g.attempts.insert(id, attempt);
Ok(id)
}
fn get_attempt(&self, id: DeliveryAttemptId) -> Result<DeliveryAttempt> {
let g = self.inner.lock().expect("poisoned");
g.attempts
.get(&id)
.cloned()
.ok_or_else(|| Error::AttemptNotFound(id.to_string()))
}
fn list_attempts(
&self,
event_id: WebhookEventId,
endpoint_id: EndpointId,
) -> Result<Vec<DeliveryAttempt>> {
let g = self.inner.lock().expect("poisoned");
let mut v: Vec<DeliveryAttempt> = g
.attempts
.values()
.filter(|a| a.event_id == event_id && a.endpoint_id == endpoint_id)
.cloned()
.collect();
v.sort_by_key(|a| a.attempt_number);
Ok(v)
}
fn list_due_retries(&self, now_unix_secs: u64) -> Result<Vec<DeliveryAttempt>> {
let g = self.inner.lock().expect("poisoned");
Ok(g.attempts
.values()
.filter(|a| {
a.status == DeliveryStatus::RetryScheduled
&& a.next_attempt_at_unix_secs
.map(|t| t <= now_unix_secs)
.unwrap_or(false)
})
.cloned()
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn endpoint() -> Endpoint {
Endpoint::new(
"https://merchant.example.com/h",
b"secret".to_vec(),
vec!["*".to_string(), "ledger.txn.posted".to_string()],
)
.unwrap()
}
#[test]
fn put_and_get_endpoint() {
let store = InMemoryWebhookStore::new();
let e = endpoint();
let id = e.id;
store.put_endpoint(e.clone()).unwrap();
let recovered = store.get_endpoint(id).unwrap();
assert_eq!(recovered, e);
}
#[test]
fn get_unknown_endpoint_errors() {
let store = InMemoryWebhookStore::new();
let r = store.get_endpoint(EndpointId::new());
assert!(matches!(r, Err(Error::EndpointNotFound(_))));
}
#[test]
fn list_active_filters_by_status_and_match() {
let store = InMemoryWebhookStore::new();
// Endpoint #1: active, subscribes to ledger.txn.posted only.
let e1 = Endpoint::new(
"https://x.example",
b"s".to_vec(),
vec!["ledger.txn.posted".to_string()],
)
.unwrap();
let e1_id = e1.id;
store.put_endpoint(e1).unwrap();
// Endpoint #2: wildcard.
let e2 = Endpoint::new("https://y.example", b"s".to_vec(), vec!["*".to_string()]).unwrap();
let e2_id = e2.id;
store.put_endpoint(e2).unwrap();
// Endpoint #3: wildcard but disabled.
let mut e3 =
Endpoint::new("https://z.example", b"s".to_vec(), vec!["*".to_string()]).unwrap();
e3.status = EndpointStatus::Disabled;
let e3_id = e3.id;
store.put_endpoint(e3).unwrap();
// For "ledger.txn.posted": e1 + e2 match; e3 disabled.
let matched = store
.list_active_endpoints_for("ledger.txn.posted")
.unwrap();
let ids: Vec<_> = matched.iter().map(|e| e.id).collect();
assert!(ids.contains(&e1_id));
assert!(ids.contains(&e2_id));
assert!(!ids.contains(&e3_id));
// For "anything_else": only e2 (wildcard).
let matched = store.list_active_endpoints_for("anything_else").unwrap();
let ids: Vec<_> = matched.iter().map(|e| e.id).collect();
assert!(!ids.contains(&e1_id));
assert!(ids.contains(&e2_id));
}
#[test]
fn set_endpoint_status() {
let store = InMemoryWebhookStore::new();
let e = endpoint();
let id = e.id;
store.put_endpoint(e).unwrap();
store
.set_endpoint_status(id, EndpointStatus::AutoDisabled)
.unwrap();
assert_eq!(
store.get_endpoint(id).unwrap().status,
EndpointStatus::AutoDisabled
);
}
#[test]
fn set_endpoint_status_unknown_id_errors() {
let store = InMemoryWebhookStore::new();
let r = store.set_endpoint_status(EndpointId::new(), EndpointStatus::Disabled);
assert!(matches!(r, Err(Error::EndpointNotFound(_))));
}
#[test]
fn set_endpoint_consecutive_failures() {
let store = InMemoryWebhookStore::new();
let e = endpoint();
let id = e.id;
store.put_endpoint(e).unwrap();
store.set_endpoint_consecutive_failures(id, 5).unwrap();
assert_eq!(store.get_endpoint(id).unwrap().consecutive_failures, 5);
}
#[test]
fn put_and_get_event() {
let store = InMemoryWebhookStore::new();
let e = WebhookEvent::new("payment.authorized", b"{}".to_vec(), 0);
let id = e.id;
store.put_event(e.clone()).unwrap();
assert_eq!(store.get_event(id).unwrap(), e);
}
#[test]
fn list_attempts_sorted_by_number() {
let store = InMemoryWebhookStore::new();
let eid = WebhookEventId::new();
let epid = EndpointId::new();
// Insert attempts out of order.
let a2 = DeliveryAttempt::new_pending(eid, epid, 2, 200);
let a0 = DeliveryAttempt::new_pending(eid, epid, 0, 0);
let a1 = DeliveryAttempt::new_pending(eid, epid, 1, 100);
store.put_attempt(a2).unwrap();
store.put_attempt(a0).unwrap();
store.put_attempt(a1).unwrap();
let list = store.list_attempts(eid, epid).unwrap();
let numbers: Vec<u32> = list.iter().map(|a| a.attempt_number).collect();
assert_eq!(numbers, vec![0, 1, 2]);
}
#[test]
fn list_due_retries_filters_by_status_and_time() {
let store = InMemoryWebhookStore::new();
let eid = WebhookEventId::new();
let epid = EndpointId::new();
// Due now.
let mut a1 = DeliveryAttempt::new_pending(eid, epid, 0, 0);
a1.status = DeliveryStatus::RetryScheduled;
a1.next_attempt_at_unix_secs = Some(100);
// Due in the future.
let mut a2 = DeliveryAttempt::new_pending(eid, epid, 1, 0);
a2.status = DeliveryStatus::RetryScheduled;
a2.next_attempt_at_unix_secs = Some(500);
// Already failed (terminal) — not due even if scheduled time
// passed.
let mut a3 = DeliveryAttempt::new_pending(eid, epid, 2, 0);
a3.status = DeliveryStatus::Failed;
a3.next_attempt_at_unix_secs = Some(50);
let a1_id = a1.id;
store.put_attempt(a1).unwrap();
store.put_attempt(a2).unwrap();
store.put_attempt(a3).unwrap();
let due = store.list_due_retries(200).unwrap();
assert_eq!(due.len(), 1);
assert_eq!(due[0].id, a1_id);
}
#[test]
fn diagnostic_counters() {
let store = InMemoryWebhookStore::new();
store.put_endpoint(endpoint()).unwrap();
let e = WebhookEvent::new("t", vec![], 0);
let eid = e.id;
store.put_event(e).unwrap();
store
.put_attempt(DeliveryAttempt::new_pending(eid, EndpointId::new(), 0, 0))
.unwrap();
assert_eq!(store.endpoint_count(), 1);
assert_eq!(store.event_count(), 1);
assert_eq!(store.attempt_count(), 1);
}
}