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:
asepharyana
2026-08-28 22:24:18 +07:00
parent 6a4b2b8f54
commit db4f277336
47 changed files with 0 additions and 0 deletions
+163
View File
@@ -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");
}
}
+60
View File
@@ -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;
+147
View File
@@ -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);
}
}
+95
View File
@@ -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");
}
}
+71
View File
@@ -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>;
}
+103
View File
@@ -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
}
);
}
}