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:
1 parent
6a4b2b8f54
commit
db4f277336
47 files changed
No files matched your search
@@ -0,0 +1,43 @@
|
||||
[package]
|
||||
name = "corex-event"
|
||||
version = "1.2.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/asepharyana/mytheclipse"
|
||||
homepage = "https://github.com/asepharyana/mytheclipse"
|
||||
documentation = "https://docs.rs/corex-event"
|
||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||
description = "Unified events & message bus abstraction: in-memory pub/sub dispatcher plus pluggable distributed broker backends (RabbitMQ, NATS)."
|
||||
readme = "README.md"
|
||||
keywords = ["events", "pubsub", "message-bus", "rabbitmq", "nats"]
|
||||
categories = ["asynchronous", "network-programming"]
|
||||
|
||||
[features]
|
||||
default = ["mem"]
|
||||
# In-memory dispatcher (tokio broadcast channel), always cheap.
|
||||
mem = ["dep:tokio"]
|
||||
# Distributed broker backends.
|
||||
amqp = ["dep:lapin", "dep:futures-lite"]
|
||||
nats = ["dep:async-nats", "dep:futures-util", "dep:bytes"]
|
||||
|
||||
[dependencies]
|
||||
tracing = "0.1"
|
||||
# Required unconditionally: the core `EventBus`/`Subscription` traits (always
|
||||
# compiled) use it.
|
||||
async-trait = "0.1"
|
||||
tokio = { version = "1.53", features = ["sync", "rt"], optional = true }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# RabbitMQ (pure Rust AMQP 0.9.1 client).
|
||||
lapin = { version = "2", optional = true }
|
||||
futures-lite = { version = "2", optional = true }
|
||||
|
||||
# NATS (pure Rust client).
|
||||
async-nats = { version = "0.38", optional = true }
|
||||
futures-util = { version = "0.3", optional = true }
|
||||
bytes = { version = "1", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53", features = ["full"] }
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 The corex Authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 The corex Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,36 @@
|
||||
# corex-event
|
||||
|
||||
A unified events & message bus abstraction so component-to-component (or
|
||||
service-to-service) communication isn't locked to one transport.
|
||||
|
||||
- **In-memory dispatcher** (default) — `tokio::sync::broadcast`-backed
|
||||
pub/sub for communication inside a single process (monolith).
|
||||
- **Distributed broker backends** — RabbitMQ (`amqp`) and NATS (`nats`)
|
||||
behind the same `EventBus` trait, so swapping the backend doesn't change
|
||||
handler code.
|
||||
- **Typed convenience** — `TypedEventBus` layers JSON encoding on top.
|
||||
|
||||
## Features
|
||||
|
||||
- `mem` (default) — in-memory dispatcher.
|
||||
- `amqp` — RabbitMQ via `lapin` (pure Rust AMQP 0.9.1 client).
|
||||
- `nats` — NATS via `async-nats`.
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use corex_event::{InMemoryEventBus, TypedEventBus};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
struct OrderCreated { id: u64 }
|
||||
|
||||
let bus = TypedEventBus::new(InMemoryEventBus::default());
|
||||
let mut sub = bus.subscribe::<OrderCreated>("orders").await?;
|
||||
bus.publish("orders", &OrderCreated { id: 42 }).await?;
|
||||
let event = sub.recv().await?;
|
||||
```
|
||||
|
||||
Swap `InMemoryEventBus::default()` for `AmqpEventBus::connect(url, "exchange").await?`
|
||||
or `NatsEventBus::connect(url).await?` to move to a distributed broker without
|
||||
touching handler code.
|
||||
@@ -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