feat: add corex-storage crate for unified storage abstraction
- Introduced corex-storage crate with support for local disk, S3-compatible, and Google Cloud Storage backends. - Implemented StorageDriver trait for various storage backends. - Added LocalFileStorage for local disk operations. - Added S3Storage for S3-compatible object storage with multipart upload support. - Added GcsStorage for Google Cloud Storage operations. - Included error handling for storage operations. - Added tests for each storage backend to ensure functionality. - Created README.md for documentation and usage examples. - Added Apache and MIT licenses for open-source compliance.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
//! A RabbitMQ-backed [`EventBus`] (feature `amqp`), via the pure-Rust `lapin`
|
||||
//! AMQP 0.9.1 client.
|
||||
//!
|
||||
//! Topics map to routing keys on a single topic [`lapin::ExchangeKind::Topic`]
|
||||
//! exchange (default name `corex.events`); each subscription declares its own
|
||||
//! exclusive, auto-delete queue bound to that routing key, matching the
|
||||
//! common "fanout via topic exchange" pattern.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures_lite::StreamExt;
|
||||
use lapin::options::{
|
||||
BasicAckOptions, BasicConsumeOptions, BasicPublishOptions, ExchangeDeclareOptions,
|
||||
QueueBindOptions, QueueDeclareOptions,
|
||||
};
|
||||
use lapin::types::FieldTable;
|
||||
use lapin::{BasicProperties, Channel, Connection, ConnectionProperties, ExchangeKind};
|
||||
|
||||
use crate::traits::{EventBus, EventError, Subscription, SubscriptionInner};
|
||||
|
||||
fn map_err(e: lapin::Error) -> EventError {
|
||||
EventError::Io(e.to_string())
|
||||
}
|
||||
|
||||
/// An [`EventBus`] backed by a RabbitMQ topic exchange.
|
||||
#[derive(Clone)]
|
||||
pub struct AmqpEventBus {
|
||||
channel: Channel,
|
||||
exchange: String,
|
||||
}
|
||||
|
||||
impl AmqpEventBus {
|
||||
/// Connects to `addr` (an AMQP URL, e.g. `amqp://guest:guest@localhost:5672/%2f`)
|
||||
/// and declares the topic exchange `exchange`.
|
||||
pub async fn connect(addr: &str, exchange: &str) -> Result<Self, EventError> {
|
||||
let conn = Connection::connect(addr, ConnectionProperties::default())
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
let channel = conn.create_channel().await.map_err(map_err)?;
|
||||
channel
|
||||
.exchange_declare(
|
||||
exchange,
|
||||
ExchangeKind::Topic,
|
||||
ExchangeDeclareOptions {
|
||||
durable: true,
|
||||
..Default::default()
|
||||
},
|
||||
FieldTable::default(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
Ok(Self {
|
||||
channel,
|
||||
exchange: exchange.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Wraps an already-open channel with a pre-declared exchange (advanced
|
||||
/// use: sharing a channel/connection across multiple buses).
|
||||
pub fn from_channel(channel: Channel, exchange: String) -> Self {
|
||||
Self { channel, exchange }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventBus for AmqpEventBus {
|
||||
async fn publish(&self, topic: &str, payload: Vec<u8>) -> Result<(), EventError> {
|
||||
self.channel
|
||||
.basic_publish(
|
||||
&self.exchange,
|
||||
topic,
|
||||
BasicPublishOptions::default(),
|
||||
&payload,
|
||||
BasicProperties::default(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_err)?
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn subscribe(&self, topic: &str) -> Result<Subscription, EventError> {
|
||||
let queue = self
|
||||
.channel
|
||||
.queue_declare(
|
||||
"", // server-generated name
|
||||
QueueDeclareOptions {
|
||||
exclusive: true,
|
||||
auto_delete: true,
|
||||
..Default::default()
|
||||
},
|
||||
FieldTable::default(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
self.channel
|
||||
.queue_bind(
|
||||
queue.name().as_str(),
|
||||
&self.exchange,
|
||||
topic,
|
||||
QueueBindOptions::default(),
|
||||
FieldTable::default(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
let consumer = self
|
||||
.channel
|
||||
.basic_consume(
|
||||
queue.name().as_str(),
|
||||
"corex-event-consumer",
|
||||
BasicConsumeOptions::default(),
|
||||
FieldTable::default(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
Ok(Subscription::new(Box::new(AmqpSubscription { consumer })))
|
||||
}
|
||||
}
|
||||
|
||||
struct AmqpSubscription {
|
||||
consumer: lapin::Consumer,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SubscriptionInner for AmqpSubscription {
|
||||
async fn recv(&mut self) -> Result<Vec<u8>, EventError> {
|
||||
match self.consumer.next().await {
|
||||
Some(Ok(delivery)) => {
|
||||
let data = delivery.data.clone();
|
||||
delivery
|
||||
.ack(BasicAckOptions::default())
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
Ok(data)
|
||||
}
|
||||
Some(Err(e)) => Err(map_err(e)),
|
||||
None => Err(EventError::Closed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Requires a live RabbitMQ at `AMQP_URL` (e.g.
|
||||
/// `amqp://guest:guest@127.0.0.1:5672/%2f`). Run with:
|
||||
/// `AMQP_URL=... cargo test -p corex-event --features amqp -- --ignored`.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a live RabbitMQ instance (AMQP_URL)"]
|
||||
async fn publish_subscribe_roundtrip_live() {
|
||||
let url = std::env::var("AMQP_URL").expect("set AMQP_URL");
|
||||
let bus = AmqpEventBus::connect(&url, "corex_event_test")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut sub = bus.subscribe("orders.created").await.unwrap();
|
||||
bus.publish("orders.created", b"hello".to_vec())
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = sub.recv().await.unwrap();
|
||||
assert_eq!(msg, b"hello");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! # corex-event
|
||||
//!
|
||||
//! A unified events & message bus abstraction so component-to-component (or
|
||||
//! service-to-service) communication doesn't get locked to one transport.
|
||||
//!
|
||||
//! - **In-memory dispatcher** ([`memory::InMemoryEventBus`], feature `mem`,
|
||||
//! default): a `tokio::sync::broadcast`-backed pub/sub bus for
|
||||
//! intra-process communication.
|
||||
//! - **Distributed broker backends**: [`amqp::AmqpEventBus`] (RabbitMQ,
|
||||
//! feature `amqp`) and [`nats::NatsEventBus`] (NATS, feature `nats`) behind
|
||||
//! the same [`EventBus`] trait, so swapping the backend does not change
|
||||
//! handler code.
|
||||
//! - **Typed convenience**: [`typed::TypedEventBus`] layers JSON
|
||||
//! (de)serialization on top of the byte-oriented [`EventBus`].
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```
|
||||
//! use corex_event::{EventBus, InMemoryEventBus, TypedEventBus};
|
||||
//! use serde::{Deserialize, Serialize};
|
||||
//!
|
||||
//! #[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
//! struct OrderCreated { id: u64 }
|
||||
//!
|
||||
//! # #[tokio::main] async fn main() {
|
||||
//! let bus = TypedEventBus::new(InMemoryEventBus::default());
|
||||
//! let mut sub = bus.subscribe::<OrderCreated>("orders").await.unwrap();
|
||||
//! bus.publish("orders", &OrderCreated { id: 42 }).await.unwrap();
|
||||
//! let event = sub.recv().await.unwrap();
|
||||
//! assert_eq!(event, OrderCreated { id: 42 });
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
pub mod traits;
|
||||
|
||||
#[cfg(feature = "mem")]
|
||||
pub mod memory;
|
||||
|
||||
#[cfg(feature = "mem")]
|
||||
pub mod typed;
|
||||
|
||||
#[cfg(feature = "amqp")]
|
||||
pub mod amqp;
|
||||
|
||||
#[cfg(feature = "nats")]
|
||||
pub mod nats;
|
||||
|
||||
pub use traits::{EventBus, EventError, Subscription};
|
||||
|
||||
#[cfg(feature = "mem")]
|
||||
pub use memory::InMemoryEventBus;
|
||||
|
||||
#[cfg(feature = "mem")]
|
||||
pub use typed::{Event, TypedEventBus, TypedSubscription};
|
||||
|
||||
#[cfg(feature = "amqp")]
|
||||
pub use amqp::AmqpEventBus;
|
||||
|
||||
#[cfg(feature = "nats")]
|
||||
pub use nats::NatsEventBus;
|
||||
@@ -0,0 +1,147 @@
|
||||
//! In-memory pub/sub dispatcher (feature `mem`), backed by
|
||||
//! [`tokio::sync::broadcast`].
|
||||
//!
|
||||
//! Suitable for communication between components inside a single process
|
||||
//! (monolith). Each topic lazily gets its own broadcast channel on first use.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::traits::{EventBus, EventError, Subscription, SubscriptionInner};
|
||||
|
||||
const DEFAULT_CAPACITY: usize = 256;
|
||||
|
||||
/// An in-process [`EventBus`] backed by a `tokio::sync::broadcast` channel per
|
||||
/// topic.
|
||||
#[derive(Clone)]
|
||||
pub struct InMemoryEventBus {
|
||||
channels: Arc<Mutex<HashMap<String, broadcast::Sender<Vec<u8>>>>>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl Default for InMemoryEventBus {
|
||||
fn default() -> Self {
|
||||
Self::new(DEFAULT_CAPACITY)
|
||||
}
|
||||
}
|
||||
|
||||
impl InMemoryEventBus {
|
||||
/// Builds a bus whose per-topic channels buffer up to `capacity` messages
|
||||
/// for slow subscribers before older ones are dropped (lagged).
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
channels: Arc::new(Mutex::new(HashMap::new())),
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
|
||||
fn sender_for(&self, topic: &str) -> broadcast::Sender<Vec<u8>> {
|
||||
let mut channels = self.channels.lock().unwrap();
|
||||
channels
|
||||
.entry(topic.to_string())
|
||||
.or_insert_with(|| broadcast::channel(self.capacity).0)
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The number of currently subscribed receivers for `topic` (0 if the
|
||||
/// topic has never been touched or has no active subscribers).
|
||||
pub fn subscriber_count(&self, topic: &str) -> usize {
|
||||
self.channels
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(topic)
|
||||
.map(|tx| tx.receiver_count())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventBus for InMemoryEventBus {
|
||||
async fn publish(&self, topic: &str, payload: Vec<u8>) -> Result<(), EventError> {
|
||||
let tx = self.sender_for(topic);
|
||||
// A `SendError` here just means there are currently no subscribers,
|
||||
// which is not an error condition for a pub/sub bus.
|
||||
let _ = tx.send(payload);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn subscribe(&self, topic: &str) -> Result<Subscription, EventError> {
|
||||
let rx = self.sender_for(topic).subscribe();
|
||||
Ok(Subscription::new(Box::new(BroadcastSubscription(rx))))
|
||||
}
|
||||
}
|
||||
|
||||
struct BroadcastSubscription(broadcast::Receiver<Vec<u8>>);
|
||||
|
||||
#[async_trait]
|
||||
impl SubscriptionInner for BroadcastSubscription {
|
||||
async fn recv(&mut self) -> Result<Vec<u8>, EventError> {
|
||||
loop {
|
||||
match self.0.recv().await {
|
||||
Ok(payload) => return Ok(payload),
|
||||
// A slow subscriber missed some messages; skip ahead rather
|
||||
// than erroring, matching typical broadcast semantics.
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => return Err(EventError::Closed),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_subscribe_roundtrip() {
|
||||
let bus = InMemoryEventBus::default();
|
||||
let mut sub = bus.subscribe("orders").await.unwrap();
|
||||
bus.publish("orders", b"order-created".to_vec())
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = sub.recv().await.unwrap();
|
||||
assert_eq!(msg, b"order-created");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_subscribers_all_receive() {
|
||||
let bus = InMemoryEventBus::default();
|
||||
let mut sub_a = bus.subscribe("t").await.unwrap();
|
||||
let mut sub_b = bus.subscribe("t").await.unwrap();
|
||||
bus.publish("t", b"hello".to_vec()).await.unwrap();
|
||||
assert_eq!(sub_a.recv().await.unwrap(), b"hello");
|
||||
assert_eq!(sub_b.recv().await.unwrap(), b"hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_with_no_subscribers_is_ok() {
|
||||
let bus = InMemoryEventBus::default();
|
||||
assert!(bus.publish("nobody-listening", b"x".to_vec()).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn different_topics_are_isolated() {
|
||||
let bus = InMemoryEventBus::default();
|
||||
let mut sub = bus.subscribe("a").await.unwrap();
|
||||
bus.publish("b", b"for-b".to_vec()).await.unwrap();
|
||||
// Nothing arrives on `a`'s subscription; use a short timeout to prove it.
|
||||
let result = tokio::time::timeout(std::time::Duration::from_millis(30), sub.recv()).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"subscription on `a` should not receive `b`'s event"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscriber_count_tracks_active_subscriptions() {
|
||||
let bus = InMemoryEventBus::default();
|
||||
assert_eq!(bus.subscriber_count("t"), 0);
|
||||
let sub = bus.subscribe("t").await.unwrap();
|
||||
assert_eq!(bus.subscriber_count("t"), 1);
|
||||
drop(sub);
|
||||
assert_eq!(bus.subscriber_count("t"), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! A NATS-backed [`EventBus`] (feature `nats`), via the pure-Rust
|
||||
//! `async-nats` client.
|
||||
//!
|
||||
//! Topics map directly onto NATS subjects.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
use crate::traits::{EventBus, EventError, Subscription, SubscriptionInner};
|
||||
|
||||
fn map_connect_err(e: async_nats::ConnectError) -> EventError {
|
||||
EventError::Io(e.to_string())
|
||||
}
|
||||
|
||||
fn map_publish_err(e: async_nats::PublishError) -> EventError {
|
||||
EventError::Io(e.to_string())
|
||||
}
|
||||
|
||||
fn map_subscribe_err(e: async_nats::SubscribeError) -> EventError {
|
||||
EventError::Io(e.to_string())
|
||||
}
|
||||
|
||||
/// An [`EventBus`] backed by a NATS client.
|
||||
#[derive(Clone)]
|
||||
pub struct NatsEventBus {
|
||||
client: async_nats::Client,
|
||||
}
|
||||
|
||||
impl NatsEventBus {
|
||||
/// Connects to a NATS server at `addr` (e.g. `nats://127.0.0.1:4222`).
|
||||
pub async fn connect(addr: &str) -> Result<Self, EventError> {
|
||||
let client = async_nats::connect(addr).await.map_err(map_connect_err)?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
/// Wraps an already-connected client.
|
||||
pub fn from_client(client: async_nats::Client) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventBus for NatsEventBus {
|
||||
async fn publish(&self, topic: &str, payload: Vec<u8>) -> Result<(), EventError> {
|
||||
self.client
|
||||
.publish(topic.to_string(), payload.into())
|
||||
.await
|
||||
.map_err(map_publish_err)
|
||||
}
|
||||
|
||||
async fn subscribe(&self, topic: &str) -> Result<Subscription, EventError> {
|
||||
let subscriber = self
|
||||
.client
|
||||
.subscribe(topic.to_string())
|
||||
.await
|
||||
.map_err(map_subscribe_err)?;
|
||||
Ok(Subscription::new(Box::new(NatsSubscription { subscriber })))
|
||||
}
|
||||
}
|
||||
|
||||
struct NatsSubscription {
|
||||
subscriber: async_nats::Subscriber,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SubscriptionInner for NatsSubscription {
|
||||
async fn recv(&mut self) -> Result<Vec<u8>, EventError> {
|
||||
match self.subscriber.next().await {
|
||||
Some(msg) => Ok(msg.payload.to_vec()),
|
||||
None => Err(EventError::Closed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Requires a live NATS server at `NATS_URL` (e.g. `nats://127.0.0.1:4222`).
|
||||
/// Run with: `NATS_URL=... cargo test -p corex-event --features nats -- --ignored`.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a live NATS instance (NATS_URL)"]
|
||||
async fn publish_subscribe_roundtrip_live() {
|
||||
let url = std::env::var("NATS_URL").expect("set NATS_URL");
|
||||
let bus = NatsEventBus::connect(&url).await.unwrap();
|
||||
let mut sub = bus.subscribe("orders.created").await.unwrap();
|
||||
// Give the subscription a moment to register server-side.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
bus.publish("orders.created", b"hello".to_vec())
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = sub.recv().await.unwrap();
|
||||
assert_eq!(msg, b"hello");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! The core [`EventBus`] trait and byte-oriented [`Subscription`] handle.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Errors returned by event bus operations.
|
||||
#[derive(Debug)]
|
||||
pub enum EventError {
|
||||
/// The backend connection failed or was lost.
|
||||
Io(String),
|
||||
/// A value could not be serialized / deserialized.
|
||||
Serialization(String),
|
||||
/// The subscription's channel was closed (no more events will arrive).
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for EventError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(s) => write!(f, "event bus io: {s}"),
|
||||
Self::Serialization(s) => write!(f, "event serialization: {s}"),
|
||||
Self::Closed => write!(f, "subscription closed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EventError {}
|
||||
|
||||
/// A byte-oriented publish/subscribe bus.
|
||||
///
|
||||
/// Implementations back this with an in-memory broadcast channel or a
|
||||
/// distributed broker (RabbitMQ, NATS, ...). Topics are opaque strings;
|
||||
/// backends may map them onto exchanges/subjects as appropriate.
|
||||
#[async_trait]
|
||||
pub trait EventBus: Send + Sync {
|
||||
/// Publishes `payload` to `topic`. Whether publishing with zero
|
||||
/// subscribers is an error is backend-defined (the in-memory backend
|
||||
/// treats it as a no-op success).
|
||||
async fn publish(&self, topic: &str, payload: Vec<u8>) -> Result<(), EventError>;
|
||||
|
||||
/// Subscribes to `topic`, returning a handle to receive future messages.
|
||||
async fn subscribe(&self, topic: &str) -> Result<Subscription, EventError>;
|
||||
}
|
||||
|
||||
/// A handle to receive messages from a subscribed topic.
|
||||
///
|
||||
/// Wraps the backend-specific receiver behind one `recv` method so callers
|
||||
/// don't need to know which [`EventBus`] implementation produced it.
|
||||
pub struct Subscription {
|
||||
inner: Box<dyn SubscriptionInner>,
|
||||
}
|
||||
|
||||
impl Subscription {
|
||||
/// Wraps a backend-specific receiver.
|
||||
///
|
||||
/// Unused when no backend feature (`mem`/`amqp`/`nats`) is enabled.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn new(inner: Box<dyn SubscriptionInner>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
/// Awaits the next message on this subscription.
|
||||
pub async fn recv(&mut self) -> Result<Vec<u8>, EventError> {
|
||||
self.inner.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
/// Object-safe receiver behind [`Subscription`].
|
||||
#[async_trait]
|
||||
pub(crate) trait SubscriptionInner: Send {
|
||||
async fn recv(&mut self) -> Result<Vec<u8>, EventError>;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! JSON-typed convenience over the byte-oriented [`EventBus`].
|
||||
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::traits::{EventBus, EventError, Subscription};
|
||||
|
||||
/// A marker trait for event payload types: anything JSON-(de)serializable.
|
||||
pub trait Event: Serialize + DeserializeOwned + Send + Sync + 'static {}
|
||||
impl<T> Event for T where T: Serialize + DeserializeOwned + Send + Sync + 'static {}
|
||||
|
||||
/// Wraps a byte-oriented [`EventBus`] with typed publish/subscribe using JSON
|
||||
/// encoding.
|
||||
#[derive(Clone)]
|
||||
pub struct TypedEventBus<B> {
|
||||
inner: Arc<B>,
|
||||
}
|
||||
|
||||
impl<B: EventBus> TypedEventBus<B> {
|
||||
/// Wraps `inner`.
|
||||
pub fn new(inner: B) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(inner),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes and publishes `event` to `topic`.
|
||||
pub async fn publish<E: Event>(&self, topic: &str, event: &E) -> Result<(), EventError> {
|
||||
let bytes =
|
||||
serde_json::to_vec(event).map_err(|e| EventError::Serialization(e.to_string()))?;
|
||||
self.inner.publish(topic, bytes).await
|
||||
}
|
||||
|
||||
/// Subscribes to `topic`, returning a [`TypedSubscription<E>`].
|
||||
pub async fn subscribe<E: Event>(
|
||||
&self,
|
||||
topic: &str,
|
||||
) -> Result<TypedSubscription<E>, EventError> {
|
||||
let inner = self.inner.subscribe(topic).await?;
|
||||
Ok(TypedSubscription {
|
||||
inner,
|
||||
_marker: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the underlying byte-oriented bus.
|
||||
pub fn inner(&self) -> &B {
|
||||
&self.inner
|
||||
}
|
||||
}
|
||||
|
||||
/// A typed view over a byte [`Subscription`], deserializing each message.
|
||||
pub struct TypedSubscription<E> {
|
||||
inner: Subscription,
|
||||
_marker: PhantomData<fn() -> E>,
|
||||
}
|
||||
|
||||
impl<E: Event> TypedSubscription<E> {
|
||||
/// Awaits and deserializes the next event.
|
||||
pub async fn recv(&mut self) -> Result<E, EventError> {
|
||||
let bytes = self.inner.recv().await?;
|
||||
serde_json::from_slice(&bytes).map_err(|e| EventError::Serialization(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::memory::InMemoryEventBus;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
struct OrderCreated {
|
||||
id: u64,
|
||||
total_cents: u64,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn typed_publish_subscribe_roundtrip() {
|
||||
let bus = TypedEventBus::new(InMemoryEventBus::default());
|
||||
let mut sub = bus.subscribe::<OrderCreated>("orders").await.unwrap();
|
||||
bus.publish(
|
||||
"orders",
|
||||
&OrderCreated {
|
||||
id: 1,
|
||||
total_cents: 4999,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let event = sub.recv().await.unwrap();
|
||||
assert_eq!(
|
||||
event,
|
||||
OrderCreated {
|
||||
id: 1,
|
||||
total_cents: 4999
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user