feat: add panic tracking and logging with PanicTracker
- Implemented PanicTracker to log panics using tracing within a dedicated span. - Introduced PanicInfo struct to capture panic details. - Added tests for panic catching and hook restoration. feat: implement token-bucket rate limiter - Created RateLimiter to manage token consumption with configurable rates and burst capacity. - Added methods for acquiring tokens and checking available tokens. - Included tests for rate limiting behavior and token availability. feat: add automatic retry with exponential backoff - Developed retry function to handle transient errors with configurable retry strategies. - Introduced RetryConfig for customizing retry behavior and jitter. - Added tests for various retry scenarios and error handling. feat: implement graceful shutdown coordination - Created ShutdownManager to manage shutdown signals and task completion. - Introduced ShutdownSignal for tasks to observe shutdown requests. - Added tests for shutdown request handling and task draining. feat: add timeout functionality for async operations - Implemented with_timeout function to enforce execution time limits on futures. - Created Timeout struct for wrapping futures with a deadline. - Added tests for timeout behavior and error mapping.
This commit is contained in:
1 parent
ca39dea5db
commit
d119821339
76 files changed
+10360
-191
No files matched your search
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "corex-cache"
|
||||
version = "0.1.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-cache"
|
||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||
description = "Unified multi-layer cache abstraction: L1/L2 caching, cache-aside and auto-refresh, with pluggable backends."
|
||||
readme = "README.md"
|
||||
keywords = ["cache", "lru", "redis", "multilayer", "cache-aside"]
|
||||
categories = ["caching", "asynchronous"]
|
||||
|
||||
[features]
|
||||
default = ["l1-memory", "cache-aside"]
|
||||
# L1 (in-process) backends.
|
||||
l1-memory = []
|
||||
l1-moka = ["l1-memory", "dep:moka"]
|
||||
# L2 (distributed) backends.
|
||||
l2-redis = ["l1-memory", "dep:redis"]
|
||||
# Cache-aside + auto-refresh helper.
|
||||
cache-aside = ["l1-memory", "dep:serde", "dep:serde_json"]
|
||||
|
||||
[dependencies]
|
||||
tracing = "0.1"
|
||||
# Required unconditionally: the core `Cache` trait (always compiled) uses it.
|
||||
async-trait = "0.1"
|
||||
serde = { version = "1", features = ["derive"], optional = true }
|
||||
serde_json = { version = "1", optional = true }
|
||||
|
||||
# L1: Moka (high-performance in-memory cache).
|
||||
moka = { version = "0.12", default-features = false, features = ["future"], optional = true }
|
||||
|
||||
# L2: Redis/Valkey async client (multiplexed connection).
|
||||
redis = { version = "0.27", default-features = false, features = ["tokio-comp"], 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,33 @@
|
||||
# corex-cache
|
||||
|
||||
A unified multi-layer cache abstraction so your app isn't locked to one cache
|
||||
provider. Combines an in-process **L1** cache with a distributed **L2** cache
|
||||
(e.g. Redis/Valkey) behind one simple `get`/`set`/`invalidate` API, plus a
|
||||
**cache-aside / auto-refresh** helper.
|
||||
|
||||
## Features
|
||||
|
||||
- `l1-memory` (default) — zero-dependency in-process cache.
|
||||
- `l1-moka` — high-performance Moka-backed L1 with TTL/max-capacity.
|
||||
- `l2-redis` — Redis/Valkey L2 via `fred`.
|
||||
- `cache-aside` (default) — read-through cache-aside helper.
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use corex_cache::{Cache, MemoryCache, MultiLayerCache, CacheAside};
|
||||
|
||||
let cache = MultiLayerCache::new(
|
||||
MemoryCache::new(), // L1
|
||||
MemoryCache::new(), // L2 (use RedisCache in production)
|
||||
);
|
||||
cache.set("k", b"v".to_vec(), None).await.unwrap();
|
||||
let v = cache.get("k").await.unwrap();
|
||||
|
||||
// Cache-aside: fill misses from a source of truth.
|
||||
let aside = CacheAside::new(
|
||||
MemoryCache::new(),
|
||||
|key| async move { Some(format!("data-for-{key}").into_bytes()) },
|
||||
);
|
||||
let _ = aside.get("orders:42").await.unwrap();
|
||||
```
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Cache-aside with read-through (feature `cache-aside`).
|
||||
//!
|
||||
//! [`CacheAside`] wires a [`Cache`] to a data source: on a miss it invokes a
|
||||
//! user-provided async fetcher, stores the result (with an optional TTL), and
|
||||
//! returns it. This is the standard cache-aside pattern — reads bypass a cold
|
||||
//! cache by falling back to the source of truth.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::traits::{Cache, CacheError};
|
||||
|
||||
/// A generic read-through cache-aside helper.
|
||||
///
|
||||
/// `F` is the data source: an async closure `(owned key) -> Option<Vec<u8>>`.
|
||||
/// The key is passed by value ([`String`]) so the returned future does not
|
||||
/// borrow from the caller, which keeps the API simple and `'static`-friendly.
|
||||
#[derive(Clone)]
|
||||
pub struct CacheAside<C, F> {
|
||||
cache: C,
|
||||
fetcher: F,
|
||||
ttl: Option<Duration>,
|
||||
}
|
||||
|
||||
impl<C, F, Fut> CacheAside<C, F>
|
||||
where
|
||||
C: Cache,
|
||||
F: Fn(String) -> Fut + Send + Sync,
|
||||
Fut: std::future::Future<Output = Option<Vec<u8>>> + Send,
|
||||
{
|
||||
/// Builds a cache-aside wrapper around `cache` using `fetcher` to fill
|
||||
/// misses. Entries are stored without expiry unless `with_ttl` is used.
|
||||
pub fn new(cache: C, fetcher: F) -> Self {
|
||||
Self {
|
||||
cache,
|
||||
fetcher,
|
||||
ttl: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a `ttl` to every entry written by this wrapper.
|
||||
pub fn with_ttl(mut self, ttl: Duration) -> Self {
|
||||
self.ttl = Some(ttl);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns a value for `key`, reading through to the fetcher on a miss and
|
||||
/// caching the result.
|
||||
pub async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
|
||||
if let Some(value) = self.cache.get(key).await? {
|
||||
return Ok(Some(value));
|
||||
}
|
||||
if let Some(value) = (self.fetcher)(key.to_string()).await {
|
||||
self.cache.set(key, value.clone(), self.ttl).await?;
|
||||
Ok(Some(value))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly evicts `key`.
|
||||
pub async fn invalidate(&self, key: &str) -> Result<(), CacheError> {
|
||||
self.cache.invalidate(key).await
|
||||
}
|
||||
|
||||
/// Returns a reference to the underlying cache.
|
||||
pub fn cache(&self) -> &C {
|
||||
&self.cache
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::memory::MemoryCache;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn fetcher(
|
||||
hits: Arc<AtomicU64>,
|
||||
) -> impl Fn(String) -> std::future::Ready<Option<Vec<u8>>> + Send + Sync {
|
||||
move |_key: String| {
|
||||
let n = hits.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
std::future::ready(Some(format!("fetched-{n}").into_bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn miss_reads_through_and_caches() {
|
||||
let hits = Arc::new(AtomicU64::new(0));
|
||||
let aside = CacheAside::new(MemoryCache::new(), fetcher(hits.clone()));
|
||||
|
||||
let first = aside.get("k").await.unwrap().unwrap();
|
||||
let second = aside.get("k").await.unwrap().unwrap();
|
||||
assert_eq!(first, b"fetched-1");
|
||||
// Cache hit — fetcher not called again.
|
||||
assert_eq!(second, b"fetched-1");
|
||||
assert_eq!(hits.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalidate_forces_refetch() {
|
||||
let hits = Arc::new(AtomicU64::new(0));
|
||||
let aside = CacheAside::new(MemoryCache::new(), fetcher(hits.clone()));
|
||||
let _ = aside.get("k").await.unwrap();
|
||||
aside.invalidate("k").await.unwrap();
|
||||
let again = aside.get("k").await.unwrap().unwrap();
|
||||
assert_eq!(again, b"fetched-2");
|
||||
assert_eq!(hits.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ttl_applies_to_writes() {
|
||||
let aside = CacheAside::new(MemoryCache::new(), fetcher(Arc::new(AtomicU64::new(0))))
|
||||
.with_ttl(Duration::from_millis(30));
|
||||
let _ = aside.get("k").await.unwrap();
|
||||
assert_eq!(
|
||||
aside.cache().get("k").await.unwrap(),
|
||||
Some(b"fetched-1".to_vec())
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(60)).await;
|
||||
assert_eq!(aside.cache().get("k").await.unwrap(), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! # corex-cache
|
||||
//!
|
||||
//! A unified multi-layer cache abstraction that keeps your application from
|
||||
//! being locked to any single cache provider.
|
||||
//!
|
||||
//! - **L1 (in-process) caches**: [`memory::MemoryCache`] (zero-dependency,
|
||||
//! default) or [`moka_cache::MokaL1`] (high-performance, TTL/max-capacity).
|
||||
//! - **L2 (distributed) caches**: [`redis::RedisCache`] backed by Redis/Valkey.
|
||||
//! - **Multi-layer composition**: [`multilayer::MultiLayerCache`] layers an L1
|
||||
//! over an L2 behind one [`Cache`] face; reads fall through to L2 and
|
||||
//! backfill L1.
|
||||
//! - **Cache-aside / auto-refresh**: [`cache_aside::CacheAside`] reads through
|
||||
//! to a data source on a miss and caches the result.
|
||||
//!
|
||||
//! The core [`Cache`] trait is byte-oriented; typed convenience (JSON) is
|
||||
//! layered on top via [`memory::typed::TypedCache`].
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use corex_cache::{Cache, MemoryCache, MultiLayerCache, CacheAside};
|
||||
//! # async fn run() {
|
||||
//! let l1 = MemoryCache::new();
|
||||
//! let l2 = MemoryCache::new(); // in a real app: a RedisCache
|
||||
//! let cache = MultiLayerCache::new(l1, l2);
|
||||
//!
|
||||
//! cache.set("user:1", b"payload".to_vec(), None).await.unwrap();
|
||||
//! assert_eq!(cache.get("user:1").await.unwrap(), Some(b"payload".to_vec()));
|
||||
//!
|
||||
//! // Cache-aside: fill misses from a source of truth.
|
||||
//! let aside = CacheAside::new(
|
||||
//! MemoryCache::new(),
|
||||
//! |key| async move { Some(format!("data-for-{key}").into_bytes()) },
|
||||
//! );
|
||||
//! let _v = aside.get("orders:42").await.unwrap();
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod traits;
|
||||
|
||||
#[cfg(feature = "l1-memory")]
|
||||
pub mod memory;
|
||||
|
||||
#[cfg(feature = "l1-moka")]
|
||||
pub mod moka_cache;
|
||||
|
||||
#[cfg(feature = "l2-redis")]
|
||||
pub mod redis;
|
||||
|
||||
#[cfg(feature = "cache-aside")]
|
||||
pub mod cache_aside;
|
||||
|
||||
#[cfg(feature = "cache-aside")]
|
||||
pub mod multilayer;
|
||||
|
||||
pub use traits::{Cache, CacheError};
|
||||
|
||||
#[cfg(feature = "l1-memory")]
|
||||
pub use memory::MemoryCache;
|
||||
|
||||
#[cfg(feature = "l1-moka")]
|
||||
pub use moka_cache::MokaL1;
|
||||
|
||||
#[cfg(feature = "l2-redis")]
|
||||
pub use redis::RedisCache;
|
||||
|
||||
#[cfg(feature = "cache-aside")]
|
||||
pub use cache_aside::CacheAside;
|
||||
|
||||
#[cfg(feature = "cache-aside")]
|
||||
pub use multilayer::MultiLayerCache;
|
||||
@@ -0,0 +1,191 @@
|
||||
//! A simple, dependency-free in-process cache (L1, `l1-memory`).
|
||||
//!
|
||||
//! Backed by a `HashMap<String, (Vec<u8>, Instant)>` guarded by a `Mutex`.
|
||||
//! Entries are lazily expired on access by comparing against `Instant`; a
|
||||
//! monotonic clock keeps TTLs robust against wall-clock discontinuities.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::traits::{Cache, CacheError};
|
||||
|
||||
/// A wrapping entry: `None` expiry means the value never expires.
|
||||
type Entry = (Vec<u8>, Option<Instant>);
|
||||
|
||||
/// An in-process [`Cache`] implementation for L1 caching.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct MemoryCache {
|
||||
inner: Arc<Mutex<HashMap<String, Entry>>>,
|
||||
}
|
||||
|
||||
impl MemoryCache {
|
||||
/// Builds an empty in-memory cache.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Pre-allocates space for `capacity` entries to reduce reallocation.
|
||||
pub fn with_capacity(self, capacity: usize) -> Self {
|
||||
self.inner.lock().unwrap().reserve(capacity);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Cache for MemoryCache {
|
||||
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
|
||||
let mut map = self.inner.lock().unwrap();
|
||||
match map.get(key) {
|
||||
Some((value, Some(expires))) if *expires <= Instant::now() => {
|
||||
map.remove(key);
|
||||
Ok(None)
|
||||
}
|
||||
Some((value, _)) => Ok(Some(value.clone())),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
key: &str,
|
||||
value: Vec<u8>,
|
||||
ttl: Option<Duration>,
|
||||
) -> Result<(), CacheError> {
|
||||
let expires = ttl.map(|d| Instant::now() + d);
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(key.to_string(), (value, expires));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn invalidate(&self, key: &str) -> Result<(), CacheError> {
|
||||
self.inner.lock().unwrap().remove(key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear(&self) -> Result<(), CacheError> {
|
||||
self.inner.lock().unwrap().clear();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A typed view over a byte cache using `serde`-compatible (JSON) encoding.
|
||||
///
|
||||
/// Only enabled with the `cache-aside` feature, which pulls in `serde`.
|
||||
#[cfg(feature = "cache-aside")]
|
||||
pub mod typed {
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Wraps a [`Cache`] with JSON-based typed get/set.
|
||||
#[derive(Clone)]
|
||||
pub struct TypedCache<C> {
|
||||
inner: C,
|
||||
}
|
||||
|
||||
impl<C: Cache> TypedCache<C> {
|
||||
/// Wraps `inner`.
|
||||
pub fn new(inner: C) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
/// Fetches and deserializes a value.
|
||||
pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, CacheError> {
|
||||
match self.inner.get(key).await? {
|
||||
Some(bytes) => serde_json::from_slice(&bytes)
|
||||
.map(Some)
|
||||
.map_err(|e| CacheError::Serialization(e.to_string())),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes and stores a value.
|
||||
pub async fn set<T: Serialize>(
|
||||
&self,
|
||||
key: &str,
|
||||
value: &T,
|
||||
ttl: Option<Duration>,
|
||||
) -> Result<(), CacheError> {
|
||||
let bytes =
|
||||
serde_json::to_vec(value).map_err(|e| CacheError::Serialization(e.to_string()))?;
|
||||
self.inner.set(key, bytes, ttl).await
|
||||
}
|
||||
|
||||
/// Returns the underlying byte cache.
|
||||
pub fn into_inner(self) -> C {
|
||||
self.inner
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_get_roundtrip() {
|
||||
let c = MemoryCache::new();
|
||||
c.set("k", b"v".to_vec(), None).await.unwrap();
|
||||
assert_eq!(c.get("k").await.unwrap(), Some(b"v".to_vec()));
|
||||
assert_eq!(c.get("missing").await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ttl_expires_entry() {
|
||||
let c = MemoryCache::new();
|
||||
c.set("k", b"v".to_vec(), Some(Duration::from_millis(30)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(c.get("k").await.unwrap(), Some(b"v".to_vec()));
|
||||
tokio::time::sleep(Duration::from_millis(60)).await;
|
||||
assert_eq!(c.get("k").await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalidate_and_clear() {
|
||||
let c = MemoryCache::new();
|
||||
c.set("a", b"1".to_vec(), None).await.unwrap();
|
||||
c.set("b", b"2".to_vec(), None).await.unwrap();
|
||||
c.invalidate("a").await.unwrap();
|
||||
assert_eq!(c.get("a").await.unwrap(), None);
|
||||
assert_eq!(c.get("b").await.unwrap(), Some(b"2".to_vec()));
|
||||
c.clear().await.unwrap();
|
||||
assert_eq!(c.get("b").await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[cfg(feature = "cache-aside")]
|
||||
#[tokio::test]
|
||||
async fn typed_cache_roundtrip() {
|
||||
use typed::TypedCache;
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
|
||||
struct User {
|
||||
id: u64,
|
||||
name: String,
|
||||
}
|
||||
let typed = TypedCache::new(MemoryCache::new());
|
||||
typed
|
||||
.set(
|
||||
"u",
|
||||
&User {
|
||||
id: 1,
|
||||
name: "alice".into(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let got: User = typed.get("u").await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
got,
|
||||
User {
|
||||
id: 1,
|
||||
name: "alice".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! A high-performance in-process cache backed by Moka (L1, `l1-moka`).
|
||||
//!
|
||||
//! Moka provides automatic max-capacity and (optionally) TTL-based eviction,
|
||||
//! so this L1 is well-suited to workloads where memory bounds matter.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use moka::future::Cache as MokaCache;
|
||||
|
||||
use crate::traits::{Cache, CacheError};
|
||||
|
||||
/// A Moka-backed [`Cache`] for L1 caching.
|
||||
#[derive(Clone)]
|
||||
pub struct MokaL1 {
|
||||
inner: MokaCache<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MokaL1 {
|
||||
/// Builds a Moka cache with `max_capacity` entries and an optional default
|
||||
/// `ttl`.
|
||||
pub fn new(max_capacity: u64, ttl: Option<Duration>) -> Self {
|
||||
let mut builder = MokaCache::builder().max_capacity(max_capacity);
|
||||
if let Some(ttl) = ttl {
|
||||
builder = builder.time_to_live(ttl);
|
||||
}
|
||||
Self {
|
||||
inner: builder.build(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Cache for MokaL1 {
|
||||
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
|
||||
Ok(self.inner.get(key).await)
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
key: &str,
|
||||
value: Vec<u8>,
|
||||
_ttl: Option<Duration>,
|
||||
) -> Result<(), CacheError> {
|
||||
// Per-entry TTL overrides are handled by the builder default in Moka;
|
||||
// the passed `ttl` is intentionally ignored (single configured policy).
|
||||
self.inner.insert(key.to_string(), value).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn invalidate(&self, key: &str) -> Result<(), CacheError> {
|
||||
self.inner.invalidate(key).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear(&self) -> Result<(), CacheError> {
|
||||
self.inner.invalidate_all();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_get_roundtrip() {
|
||||
let c = MokaL1::new(100, None);
|
||||
c.set("k", b"v".to_vec(), None).await.unwrap();
|
||||
assert_eq!(c.get("k").await.unwrap(), Some(b"v".to_vec()));
|
||||
assert_eq!(c.get("missing").await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalidate_and_clear() {
|
||||
let c = MokaL1::new(100, None);
|
||||
c.set("a", b"1".to_vec(), None).await.unwrap();
|
||||
c.set("b", b"2".to_vec(), None).await.unwrap();
|
||||
c.invalidate("a").await.unwrap();
|
||||
assert_eq!(c.get("a").await.unwrap(), None);
|
||||
assert_eq!(c.get("b").await.unwrap(), Some(b"2".to_vec()));
|
||||
c.clear().await.unwrap();
|
||||
assert_eq!(c.get("b").await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ttl_does_expire() {
|
||||
// Keep a firm TTL assertion; sleep well past the expiry window.
|
||||
let c = MokaL1::new(100, Some(Duration::from_millis(40)));
|
||||
c.set("k", b"v".to_vec(), None).await.unwrap();
|
||||
assert_eq!(c.get("k").await.unwrap(), Some(b"v".to_vec()));
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
let v = c.get("k").await.unwrap();
|
||||
assert!(matches!(v, None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Multi-layer (L1/L2) caching behind a single [`Cache`] face.
|
||||
//!
|
||||
//! [`MultiLayerCache`] layers a fast in-process L1 over a slower but larger
|
||||
//! L2 (e.g. Redis). Reads are L1-first with an L2 fallback; a hit on L2 is
|
||||
//! backfilled into L1. Writes and invalidations go to both layers.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::traits::{Cache, CacheError};
|
||||
|
||||
/// A read-through, write-through composition of an L1 and L2 cache.
|
||||
///
|
||||
/// `L1` is typically [`crate::memory::MemoryCache`] or
|
||||
/// [`crate::moka_cache::MokaL1`]; `L2` is typically a distributed cache such
|
||||
/// as a Redis backend. Order of layers fixed: `L1` is consulted first.
|
||||
#[derive(Clone)]
|
||||
pub struct MultiLayerCache<L1, L2> {
|
||||
l1: L1,
|
||||
l2: L2,
|
||||
/// When `true`, an L2 hit is written back into L1 (default `true`).
|
||||
populate_l1: bool,
|
||||
}
|
||||
|
||||
impl<L1, L2> MultiLayerCache<L1, L2>
|
||||
where
|
||||
L1: Cache,
|
||||
L2: Cache,
|
||||
{
|
||||
/// Builds a two-layer cache with L1-backfill enabled.
|
||||
pub fn new(l1: L1, l2: L2) -> Self {
|
||||
Self {
|
||||
l1,
|
||||
l2,
|
||||
populate_l1: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Disables L1 backfill-on-read.
|
||||
pub fn without_l1_backfill(mut self) -> Self {
|
||||
self.populate_l1 = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns a reference to the L1 layer.
|
||||
pub fn l1(&self) -> &L1 {
|
||||
&self.l1
|
||||
}
|
||||
|
||||
/// Returns a reference to the L2 layer.
|
||||
pub fn l2(&self) -> &L2 {
|
||||
&self.l2
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<L1, L2> Cache for MultiLayerCache<L1, L2>
|
||||
where
|
||||
L1: Cache,
|
||||
L2: Cache,
|
||||
{
|
||||
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
|
||||
// L1 first.
|
||||
if let Some(value) = self.l1.get(key).await? {
|
||||
return Ok(Some(value));
|
||||
}
|
||||
// L2 fallback.
|
||||
if let Some(value) = self.l2.get(key).await? {
|
||||
if self.populate_l1 {
|
||||
self.l1.set(key, value.clone(), None).await?;
|
||||
}
|
||||
return Ok(Some(value));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
key: &str,
|
||||
value: Vec<u8>,
|
||||
ttl: Option<Duration>,
|
||||
) -> Result<(), CacheError> {
|
||||
self.l1.set(key, value.clone(), ttl).await?;
|
||||
self.l2.set(key, value, ttl).await
|
||||
}
|
||||
|
||||
async fn invalidate(&self, key: &str) -> Result<(), CacheError> {
|
||||
self.l1.invalidate(key).await?;
|
||||
self.l2.invalidate(key).await
|
||||
}
|
||||
|
||||
async fn clear(&self) -> Result<(), CacheError> {
|
||||
self.l1.clear().await?;
|
||||
self.l2.clear().await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::memory::MemoryCache;
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_through_populates_l1() {
|
||||
let l2 = MemoryCache::new();
|
||||
l2.set("k", b"l2-value".to_vec(), None).await.unwrap();
|
||||
|
||||
let layered = MultiLayerCache::new(MemoryCache::new(), l2);
|
||||
assert_eq!(layered.l1().get("k").await.unwrap(), None);
|
||||
assert_eq!(layered.get("k").await.unwrap(), Some(b"l2-value".to_vec()));
|
||||
// L2 hit should have populated L1.
|
||||
assert_eq!(
|
||||
layered.l1().get("k").await.unwrap(),
|
||||
Some(b"l2-value".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_goes_to_both() {
|
||||
let l1 = MemoryCache::new();
|
||||
let l2 = MemoryCache::new();
|
||||
let layered = MultiLayerCache::new(l1, l2.clone());
|
||||
layered.set("k", b"v".to_vec(), None).await.unwrap();
|
||||
assert_eq!(layered.l1().get("k").await.unwrap(), Some(b"v".to_vec()));
|
||||
assert_eq!(l2.get("k").await.unwrap(), Some(b"v".to_vec()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalidate_clears_both() {
|
||||
let l1 = MemoryCache::new();
|
||||
let l2 = MemoryCache::new();
|
||||
let layered = MultiLayerCache::new(l1, l2);
|
||||
layered.set("k", b"v".to_vec(), None).await.unwrap();
|
||||
layered.invalidate("k").await.unwrap();
|
||||
assert_eq!(layered.l1().get("k").await.unwrap(), None);
|
||||
assert_eq!(layered.l2().get("k").await.unwrap(), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! A distributed (L2) cache backed by Redis / Valkey (feature `l2-redis`).
|
||||
//!
|
||||
//! Wraps a `redis` async connection (multiplexed). Values are stored as raw
|
||||
//! Redis strings with an optional TTL (`SETEX` when a TTL is given). The
|
||||
//! caller provides the connection; this type only issues cache commands.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use redis::aio::MultiplexedConnection;
|
||||
use redis::{AsyncCommands, RedisError};
|
||||
|
||||
use crate::traits::{Cache, CacheError};
|
||||
|
||||
/// Map a Redis error onto a [`CacheError`].
|
||||
fn map_err(e: RedisError) -> CacheError {
|
||||
CacheError::Io(e.to_string())
|
||||
}
|
||||
|
||||
/// An L2 cache backed by a `redis` [`MultiplexedConnection`].
|
||||
///
|
||||
/// The connection is supplied by the caller; it is cheaply cloned (the
|
||||
/// multiplexed connection is `Arc`-backed internally), so one pool can drive
|
||||
/// both cache operations and other Redis usage.
|
||||
#[derive(Clone)]
|
||||
pub struct RedisCache {
|
||||
conn: MultiplexedConnection,
|
||||
/// Optional namespace prefix prepended to every key.
|
||||
prefix: String,
|
||||
}
|
||||
|
||||
impl RedisCache {
|
||||
/// Wraps an existing connection.
|
||||
pub fn new(conn: MultiplexedConnection) -> Self {
|
||||
Self::with_prefix(conn, String::new())
|
||||
}
|
||||
|
||||
/// Wraps a connection and adds a namespace prefix to every key.
|
||||
pub fn with_prefix(conn: MultiplexedConnection, prefix: String) -> Self {
|
||||
Self { conn, prefix }
|
||||
}
|
||||
|
||||
fn key(&self, key: &str) -> String {
|
||||
if self.prefix.is_empty() {
|
||||
key.to_string()
|
||||
} else {
|
||||
format!("{}{}", self.prefix, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Cache for RedisCache {
|
||||
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError> {
|
||||
// `get::<_, Option<Vec<u8>>>` returns `None` for a missing key.
|
||||
let mut c = self.conn.clone();
|
||||
let k = self.key(key);
|
||||
let result: Result<Option<Vec<u8>>, RedisError> = c.get(&k).await;
|
||||
result.map_err(map_err)
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
key: &str,
|
||||
value: Vec<u8>,
|
||||
ttl: Option<Duration>,
|
||||
) -> Result<(), CacheError> {
|
||||
let mut c = self.conn.clone();
|
||||
let k = self.key(key);
|
||||
match ttl {
|
||||
Some(ttl) => {
|
||||
let secs = ttl.as_secs().max(1);
|
||||
let result: Result<(), RedisError> = c.set_ex(&k, value, secs).await;
|
||||
result.map_err(map_err)
|
||||
}
|
||||
None => {
|
||||
let result: Result<(), RedisError> = c.set(&k, value).await;
|
||||
result.map_err(map_err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn invalidate(&self, key: &str) -> Result<(), CacheError> {
|
||||
let mut c = self.conn.clone();
|
||||
let k = self.key(key);
|
||||
let result: Result<u64, RedisError> = c.del(&k).await;
|
||||
result.map(|_| ()).map_err(map_err)
|
||||
}
|
||||
|
||||
async fn clear(&self) -> Result<(), CacheError> {
|
||||
// Deliberately does nothing: `FLUSHALL`/`FLUSHDB` are dangerous on a
|
||||
// shared instance. Consumers should scope keys under a prefix and call
|
||||
// `invalidate` for the keys they own.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Integration test requiring a live Redis at `REDIS_URL`
|
||||
/// (e.g. `redis://127.0.0.1:6379`). Run with:
|
||||
/// `REDIS_URL=redis://127.0.0.1:6379 cargo test -p corex-cache --features l2-redis -- --ignored` .
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a live Redis instance (REDIS_URL)"]
|
||||
async fn set_get_roundtrip_live() {
|
||||
let url = std::env::var("REDIS_URL").expect("set REDIS_URL");
|
||||
let client = redis::Client::open(url).expect("valid redis url");
|
||||
let conn = client
|
||||
.get_multiplexed_tokio_connection()
|
||||
.await
|
||||
.expect("connect");
|
||||
let cache = RedisCache::with_prefix(conn, "corex_cache_test:".to_string());
|
||||
|
||||
cache
|
||||
.set("k", b"v".to_vec(), Some(Duration::from_secs(3600)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cache.get("k").await.unwrap(), Some(b"v".to_vec()));
|
||||
cache.invalidate("k").await.unwrap();
|
||||
assert_eq!(cache.get("k").await.unwrap(), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! The core [`Cache`] and [`KeyEncoder`] traits.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Errors returned by cache operations.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CacheError {
|
||||
/// The backend could not be reached (e.g. Redis connection lost).
|
||||
Io(String),
|
||||
/// A value could not be serialized / deserialized.
|
||||
Serialization(String),
|
||||
/// A key could not be encoded for the backend.
|
||||
Key(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CacheError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(s) => write!(f, "cache io: {s}"),
|
||||
Self::Serialization(s) => write!(f, "cache serialization: {s}"),
|
||||
Self::Key(s) => write!(f, "cache key: {s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CacheError {}
|
||||
|
||||
/// A generic byte-oriented cache.
|
||||
///
|
||||
/// Real caches operate on bytes or strings; typed convenience is layered on
|
||||
/// top (see [`crate::memory::typed::TypedCache`], behind `cache-aside`).
|
||||
/// Implementors control the value format.
|
||||
#[async_trait]
|
||||
pub trait Cache: Send + Sync {
|
||||
/// Fetches a value by key. `None` indicates a miss.
|
||||
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError>;
|
||||
/// Stores a value under `key`, optionally expiring after `ttl`.
|
||||
async fn set(&self, key: &str, value: Vec<u8>, ttl: Option<Duration>)
|
||||
-> Result<(), CacheError>;
|
||||
/// Removes a key.
|
||||
async fn invalidate(&self, key: &str) -> Result<(), CacheError>;
|
||||
/// Removes all entries.
|
||||
async fn clear(&self) -> Result<(), CacheError>;
|
||||
}
|
||||
|
||||
/// Keys given to the byte-oriented [`Cache`] are `&str`, but concrete backends
|
||||
/// may need richer keys. [`KeyEncoder`] turns typed keys into canonical strings.
|
||||
pub trait KeyEncoder {
|
||||
/// The "shape" of a key, e.g. `"user:{id}:profile"`.
|
||||
fn encode<C: Into<Cow<'static, str>>, R: std::fmt::Display>(parts: (C, R)) -> String;
|
||||
}
|
||||
|
||||
/// A blanket implementation that formats `{collection}:{id}`.
|
||||
pub struct DefaultKeyEncoder;
|
||||
|
||||
impl KeyEncoder for DefaultKeyEncoder {
|
||||
fn encode<C: Into<Cow<'static, str>>, R: std::fmt::Display>(parts: (C, R)) -> String {
|
||||
format!("{}:{}", parts.0.into(), parts.1)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_key_encoder_formats() {
|
||||
assert_eq!(DefaultKeyEncoder::encode(("user", 42)), "user:42");
|
||||
assert_eq!(
|
||||
DefaultKeyEncoder::encode(("session", "abc-123")),
|
||||
"session:abc-123"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
name = "corex-config"
|
||||
version = "0.1.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-config"
|
||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||
description = "Type-safe, dynamic configuration engine: load .env/YAML/JSON/TOML into typed structs with hot-reload and validation."
|
||||
readme = "README.md"
|
||||
keywords = ["config", "env", "yaml", "json", "hot-reload"]
|
||||
categories = ["config", "development-tools"]
|
||||
|
||||
[features]
|
||||
default = ["env", "yaml", "toml", "hot-reload"]
|
||||
# Load .env files + environment variables.
|
||||
env = ["dep:dotenvy"]
|
||||
# Parse structured files. JSON support (`.json`) is always available since
|
||||
# `serde_json::Value` is also the loader's internal merge representation.
|
||||
yaml = ["dep:serde_yaml"]
|
||||
toml = ["dep:toml"]
|
||||
# Watch config files and hot-reload.
|
||||
hot-reload = ["dep:notify", "dep:tokio"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tracing = "0.1"
|
||||
|
||||
# Optional loaders
|
||||
dotenvy = { version = "0.15", optional = true }
|
||||
serde_yaml = { version = "0.9", optional = true }
|
||||
toml = { version = "0.8", optional = true }
|
||||
# Hot-reload file watching
|
||||
notify = { version = "6", optional = true }
|
||||
tokio = { version = "1.53", features = ["sync", "rt", "time"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53", features = ["full"] }
|
||||
tempfile = "3"
|
||||
@@ -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,49 @@
|
||||
# corex-config
|
||||
|
||||
Type-safe, dynamic configuration: load `.env`, YAML, JSON, or TOML directly
|
||||
into a typed Rust struct, merge multiple sources with environment variables
|
||||
taking priority, and optionally hot-reload when the source files change.
|
||||
|
||||
## Features
|
||||
|
||||
- `env` (default) — `.env` file loading via `dotenvy`.
|
||||
- `yaml` / `toml` (default) — structured file parsing (`.json` is always available).
|
||||
- `hot-reload` (default) — watch files and swap in a freshly reloaded value.
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use serde::Deserialize;
|
||||
use corex_config::ConfigLoader;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct AppConfig {
|
||||
port: u16,
|
||||
database_url: String,
|
||||
}
|
||||
|
||||
let config: AppConfig = ConfigLoader::new()
|
||||
.merge_file("config.yaml".as_ref())?
|
||||
.merge_env("APP") // APP_PORT, APP_DATABASE_URL override the file
|
||||
.build()?;
|
||||
```
|
||||
|
||||
### Hot-reload
|
||||
|
||||
```rust
|
||||
use corex_config::DynamicConfig;
|
||||
# use serde::Deserialize;
|
||||
# #[derive(Debug, Deserialize, Clone)] struct AppConfig { port: u16 }
|
||||
|
||||
let cfg = DynamicConfig::<AppConfig>::watch_files(
|
||||
vec!["config.yaml".into()],
|
||||
|| corex_config::ConfigLoader::new().merge_file("config.yaml".as_ref())?.build(),
|
||||
)?;
|
||||
|
||||
let mut changes = cfg.subscribe();
|
||||
tokio::spawn(async move {
|
||||
while changes.recv().await.is_ok() {
|
||||
println!("config reloaded: {:?}", cfg.get());
|
||||
}
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Runtime configuration hot-reload (feature `hot-reload`).
|
||||
//!
|
||||
//! [`DynamicConfig`] holds a typed configuration value behind an `RwLock`,
|
||||
//! optionally watching a set of source files with `notify` and re-running a
|
||||
//! caller-supplied reload closure whenever they change. Subscribers can await
|
||||
//! a [`tokio::sync::broadcast::Receiver`] to react to a successful reload.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::{Config, ConfigError};
|
||||
|
||||
/// A hot-reloadable, thread-safe configuration handle.
|
||||
pub struct DynamicConfig<T> {
|
||||
inner: Arc<RwLock<T>>,
|
||||
tx: broadcast::Sender<()>,
|
||||
}
|
||||
|
||||
impl<T: Config + Clone> Clone for DynamicConfig<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: Arc::clone(&self.inner),
|
||||
tx: self.tx.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Config + Clone> DynamicConfig<T> {
|
||||
/// Wraps an already-loaded value with no file watching.
|
||||
pub fn new(initial: T) -> Self {
|
||||
let (tx, _rx) = broadcast::channel(16);
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(initial)),
|
||||
tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a clone of the current configuration snapshot.
|
||||
pub fn get(&self) -> T {
|
||||
self.inner
|
||||
.read()
|
||||
.expect("corex-config: RwLock poisoned")
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Replaces the current value and notifies subscribers.
|
||||
pub fn set(&self, new: T) {
|
||||
*self.inner.write().expect("corex-config: RwLock poisoned") = new;
|
||||
let _ = self.tx.send(());
|
||||
}
|
||||
|
||||
/// Subscribes to change notifications (fired after every successful
|
||||
/// [`DynamicConfig::set`] or file-triggered reload).
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<()> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
/// Loads the initial value via `reload`, then watches `paths` for
|
||||
/// modifications and re-runs `reload` on each change (debounced), atomically
|
||||
/// swapping in the result on success. A failed reload is logged via
|
||||
/// `tracing::error!` and the previous value is retained.
|
||||
///
|
||||
/// The underlying OS file watcher lives on a dedicated background thread
|
||||
/// for the lifetime of the process; there is currently no explicit
|
||||
/// "unwatch" — construct one `DynamicConfig` per watched file set.
|
||||
pub fn watch_files<F>(paths: Vec<PathBuf>, reload: F) -> Result<Self, ConfigError>
|
||||
where
|
||||
F: Fn() -> Result<T, ConfigError> + Send + Sync + 'static,
|
||||
{
|
||||
Self::watch_files_debounced(paths, reload, Duration::from_millis(50))
|
||||
}
|
||||
|
||||
/// Same as [`DynamicConfig::watch_files`] with an explicit debounce
|
||||
/// window (the minimum time between two applied reloads).
|
||||
pub fn watch_files_debounced<F>(
|
||||
paths: Vec<PathBuf>,
|
||||
reload: F,
|
||||
debounce: Duration,
|
||||
) -> Result<Self, ConfigError>
|
||||
where
|
||||
F: Fn() -> Result<T, ConfigError> + Send + Sync + 'static,
|
||||
{
|
||||
let initial = reload()?;
|
||||
let config = Self::new(initial);
|
||||
let inner = Arc::clone(&config.inner);
|
||||
let tx = config.tx.clone();
|
||||
|
||||
let (raw_tx, raw_rx) = std::sync::mpsc::channel();
|
||||
let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res| {
|
||||
let _ = raw_tx.send(res);
|
||||
})
|
||||
.map_err(|e| ConfigError::Watch(e.to_string()))?;
|
||||
for path in &paths {
|
||||
watcher
|
||||
.watch(path, RecursiveMode::NonRecursive)
|
||||
.map_err(|e| ConfigError::Watch(e.to_string()))?;
|
||||
}
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("corex-config-watch".into())
|
||||
.spawn(move || {
|
||||
// Keep the watcher alive for the life of this thread.
|
||||
let _watcher = watcher;
|
||||
let mut last_applied = Instant::now() - debounce;
|
||||
for event in raw_rx {
|
||||
if event.is_err() {
|
||||
continue;
|
||||
}
|
||||
if last_applied.elapsed() < debounce {
|
||||
continue;
|
||||
}
|
||||
match reload() {
|
||||
Ok(new) => {
|
||||
*inner.write().expect("corex-config: RwLock poisoned") = new;
|
||||
let _ = tx.send(());
|
||||
last_applied = Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("corex-config: hot-reload failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| ConfigError::Watch(e.to_string()))?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ConfigLoader;
|
||||
use serde::Deserialize;
|
||||
use std::io::Write;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, PartialEq)]
|
||||
struct Cfg {
|
||||
value: u32,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_get_roundtrip() {
|
||||
let cfg = DynamicConfig::new(Cfg { value: 1 });
|
||||
assert_eq!(cfg.get(), Cfg { value: 1 });
|
||||
cfg.set(Cfg { value: 2 });
|
||||
assert_eq!(cfg.get(), Cfg { value: 2 });
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_receives_change_notification() {
|
||||
let cfg = DynamicConfig::new(Cfg { value: 1 });
|
||||
let mut rx = cfg.subscribe();
|
||||
cfg.set(Cfg { value: 9 });
|
||||
rx.recv().await.expect("change notification");
|
||||
assert_eq!(cfg.get().value, 9);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watch_files_reloads_on_change() {
|
||||
let mut file = tempfile::Builder::new().suffix(".json").tempfile().unwrap();
|
||||
write!(file, r#"{{"value": 1}}"#).unwrap();
|
||||
let path = file.path().to_path_buf();
|
||||
|
||||
let reload_path = path.clone();
|
||||
let cfg = DynamicConfig::<Cfg>::watch_files_debounced(
|
||||
vec![path.clone()],
|
||||
move || {
|
||||
ConfigLoader::new()
|
||||
.merge_file(&reload_path)
|
||||
.and_then(|l| l.build())
|
||||
},
|
||||
Duration::from_millis(10),
|
||||
)
|
||||
.expect("watch setup");
|
||||
assert_eq!(cfg.get().value, 1);
|
||||
|
||||
let mut rx = cfg.subscribe();
|
||||
// Rewrite the file to trigger a reload.
|
||||
std::fs::write(&path, r#"{"value": 42}"#).unwrap();
|
||||
|
||||
// Wait (with a generous timeout) for the watcher thread to notice.
|
||||
let result = tokio::time::timeout(Duration::from_secs(5), rx.recv()).await;
|
||||
assert!(result.is_ok(), "expected a reload notification within 5s");
|
||||
assert_eq!(cfg.get().value, 42);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Shared error type for corex-config.
|
||||
|
||||
/// Errors surfaced while loading or reloading configuration.
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigError {
|
||||
/// An I/O error reading a config source (file not found, permissions, ...).
|
||||
Io(String),
|
||||
/// A source could not be parsed (malformed YAML/JSON/TOML).
|
||||
Parse(String),
|
||||
/// The merged configuration could not be deserialized into the target type.
|
||||
Deserialize(String),
|
||||
/// The requested file extension has no registered loader (feature not
|
||||
/// enabled, or unsupported format).
|
||||
UnsupportedFormat(String),
|
||||
/// Hot-reload setup failed (e.g. the file watcher could not be installed).
|
||||
Watch(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConfigError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(s) => write!(f, "config io error: {s}"),
|
||||
Self::Parse(s) => write!(f, "config parse error: {s}"),
|
||||
Self::Deserialize(s) => write!(f, "config deserialize error: {s}"),
|
||||
Self::UnsupportedFormat(s) => write!(f, "unsupported config format: {s}"),
|
||||
Self::Watch(s) => write!(f, "config watch error: {s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigError {}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! # corex-config
|
||||
//!
|
||||
//! A type-safe, dynamic configuration engine: load environment variables,
|
||||
//! `.env` files, YAML, JSON, or TOML directly into a typed Rust struct, with
|
||||
//! optional runtime hot-reload.
|
||||
//!
|
||||
//! - [`ConfigLoader`] merges any number of file/env sources into a single
|
||||
//! typed value (files first, environment variables override).
|
||||
//! - [`DynamicConfig`] wraps a loaded value behind an `RwLock`, optionally
|
||||
//! watching its source files and swapping in a freshly reloaded value when
|
||||
//! they change, broadcasting the change to subscribers.
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use serde::Deserialize;
|
||||
//! use corex_config::ConfigLoader;
|
||||
//!
|
||||
//! #[derive(Debug, Deserialize, Clone)]
|
||||
//! struct AppConfig {
|
||||
//! port: u16,
|
||||
//! database: DatabaseConfig,
|
||||
//! }
|
||||
//! #[derive(Debug, Deserialize, Clone)]
|
||||
//! struct DatabaseConfig {
|
||||
//! url: String,
|
||||
//! }
|
||||
//!
|
||||
//! let config: AppConfig = ConfigLoader::new()
|
||||
//! .merge_file("config.yaml".as_ref())
|
||||
//! .unwrap()
|
||||
//! .merge_env("APP")
|
||||
//! .build()
|
||||
//! .unwrap();
|
||||
//! ```
|
||||
|
||||
pub mod error;
|
||||
pub mod loader;
|
||||
|
||||
#[cfg(feature = "hot-reload")]
|
||||
pub mod dynamic;
|
||||
|
||||
pub use error::ConfigError;
|
||||
pub use loader::ConfigLoader;
|
||||
|
||||
#[cfg(feature = "hot-reload")]
|
||||
pub use dynamic::DynamicConfig;
|
||||
|
||||
/// Marker trait for types loadable by [`ConfigLoader`].
|
||||
///
|
||||
/// Blanket-implemented for any `Deserialize + Send + Sync + 'static`.
|
||||
pub trait Config: for<'de> serde::Deserialize<'de> + Send + Sync + 'static {}
|
||||
impl<T> Config for T where T: for<'de> serde::Deserialize<'de> + Send + Sync + 'static {}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Merges file and environment sources into a typed configuration value.
|
||||
|
||||
use std::marker::PhantomData;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::{Config, ConfigError};
|
||||
|
||||
/// Builds a typed configuration value from any number of sources.
|
||||
///
|
||||
/// Sources are merged in the order they're added; later sources override
|
||||
/// earlier ones at the leaf level (objects are merged recursively, not
|
||||
/// replaced wholesale). The conventional order is: defaults, then files
|
||||
/// (base -> environment-specific), then environment variables (highest
|
||||
/// priority, for secrets/overrides).
|
||||
pub struct ConfigLoader<T> {
|
||||
value: Value,
|
||||
_marker: PhantomData<fn() -> T>,
|
||||
}
|
||||
|
||||
impl<T> Default for ConfigLoader<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
value: Value::Object(Map::new()),
|
||||
_marker: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Config> ConfigLoader<T> {
|
||||
/// Builds an empty loader.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Merges a JSON value directly (useful for defaults / tests).
|
||||
pub fn merge_value(mut self, value: Value) -> Self {
|
||||
deep_merge(&mut self.value, value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Reads `path`, parses it by extension (`.yaml`/`.yml`, `.json`,
|
||||
/// `.toml`), and merges the result.
|
||||
pub fn merge_file(mut self, path: &Path) -> Result<Self, ConfigError> {
|
||||
let contents = std::fs::read_to_string(path)
|
||||
.map_err(|e| ConfigError::Io(format!("{}: {e}", path.display())))?;
|
||||
let parsed = parse_by_extension(path, &contents)?;
|
||||
deep_merge(&mut self.value, parsed);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Loads a `.env`-style file into the process environment (does not merge
|
||||
/// into the config value directly — call [`Self::merge_env`] afterward to
|
||||
/// pick the variables up).
|
||||
#[cfg(feature = "env")]
|
||||
pub fn load_dotenv(self, path: &Path) -> Result<Self, ConfigError> {
|
||||
dotenvy::from_path(path).map_err(|e| ConfigError::Io(e.to_string()))?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Merges environment variables whose name starts with `prefix` (an
|
||||
/// underscore is inserted between the prefix and the field name if not
|
||||
/// already present). Nested fields use `__` as a separator, e.g.
|
||||
/// `APP_DATABASE__URL` maps to `{ "database": { "url": ... } }`.
|
||||
///
|
||||
/// Values are parsed as JSON scalars when possible (`true`/`false`,
|
||||
/// integers, floats), otherwise kept as strings.
|
||||
pub fn merge_env(mut self, prefix: &str) -> Self {
|
||||
let collected = collect_env(prefix);
|
||||
deep_merge(&mut self.value, Value::Object(collected));
|
||||
self
|
||||
}
|
||||
|
||||
/// Deserializes the merged value into `T`.
|
||||
pub fn build(self) -> Result<T, ConfigError> {
|
||||
serde_json::from_value(self.value).map_err(|e| ConfigError::Deserialize(e.to_string()))
|
||||
}
|
||||
|
||||
/// Returns the current merged value without deserializing, for
|
||||
/// inspection/debugging.
|
||||
pub fn peek(&self) -> &Value {
|
||||
&self.value
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `contents` according to `path`'s extension.
|
||||
fn parse_by_extension(path: &Path, contents: &str) -> Result<Value, ConfigError> {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
#[cfg(feature = "yaml")]
|
||||
"yaml" | "yml" => serde_yaml::from_str(contents)
|
||||
.map_err(|e| ConfigError::Parse(e.to_string()))
|
||||
.map(yaml_to_json),
|
||||
"json" => serde_json::from_str(contents).map_err(|e| ConfigError::Parse(e.to_string())),
|
||||
#[cfg(feature = "toml")]
|
||||
"toml" => {
|
||||
let v: toml::Value =
|
||||
toml::from_str(contents).map_err(|e| ConfigError::Parse(e.to_string()))?;
|
||||
Ok(toml_to_json(v))
|
||||
}
|
||||
other => Err(ConfigError::UnsupportedFormat(format!(
|
||||
"no loader registered for `.{other}` (path: {})",
|
||||
path.display()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "yaml")]
|
||||
fn yaml_to_json(v: serde_yaml::Value) -> Value {
|
||||
// Round-trip through serde_json for a uniform merge representation.
|
||||
serde_json::to_value(v).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
#[cfg(feature = "toml")]
|
||||
fn toml_to_json(v: toml::Value) -> Value {
|
||||
serde_json::to_value(v).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// Deep-merges `overlay` into `base`; scalars and arrays in `overlay` replace
|
||||
/// `base`, objects are merged key-by-key recursively.
|
||||
fn deep_merge(base: &mut Value, overlay: Value) {
|
||||
match (base, overlay) {
|
||||
(Value::Object(base_map), Value::Object(overlay_map)) => {
|
||||
for (k, v) in overlay_map {
|
||||
match base_map.get_mut(&k) {
|
||||
Some(existing) => deep_merge(existing, v),
|
||||
None => {
|
||||
base_map.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(base_slot, overlay_value) => {
|
||||
*base_slot = overlay_value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects environment variables with `prefix` into a nested JSON object.
|
||||
///
|
||||
/// `PREFIX_FOO` -> `{"foo": ...}`; `PREFIX_FOO__BAR` -> `{"foo": {"bar": ...}}`.
|
||||
fn collect_env(prefix: &str) -> Map<String, Value> {
|
||||
let mut root = Map::new();
|
||||
let full_prefix = if prefix.ends_with('_') {
|
||||
prefix.to_string()
|
||||
} else {
|
||||
format!("{prefix}_")
|
||||
};
|
||||
for (key, raw_value) in std::env::vars() {
|
||||
let Some(rest) = key.strip_prefix(&full_prefix) else {
|
||||
continue;
|
||||
};
|
||||
if rest.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let path: Vec<String> = rest.split("__").map(|s| s.to_ascii_lowercase()).collect();
|
||||
insert_nested(&mut root, &path, coerce_scalar(&raw_value));
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
fn insert_nested(root: &mut Map<String, Value>, path: &[String], value: Value) {
|
||||
if path.len() == 1 {
|
||||
root.insert(path[0].clone(), value);
|
||||
return;
|
||||
}
|
||||
let entry = root
|
||||
.entry(path[0].clone())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if let Value::Object(nested) = entry {
|
||||
insert_nested(nested, &path[1..], value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `s` as a JSON scalar (`bool`/number) if possible, else keeps it as
|
||||
/// a string.
|
||||
fn coerce_scalar(s: &str) -> Value {
|
||||
if let Ok(b) = s.parse::<bool>() {
|
||||
return Value::Bool(b);
|
||||
}
|
||||
if let Ok(i) = s.parse::<i64>() {
|
||||
return Value::Number(i.into());
|
||||
}
|
||||
if let Ok(f) = s.parse::<f64>() {
|
||||
if let Some(n) = serde_json::Number::from_f64(f) {
|
||||
return Value::Number(n);
|
||||
}
|
||||
}
|
||||
Value::String(s.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
struct Db {
|
||||
url: String,
|
||||
pool_size: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
struct AppConfig {
|
||||
port: u16,
|
||||
debug: bool,
|
||||
database: Db,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_value_builds_typed_struct() {
|
||||
let value = serde_json::json!({
|
||||
"port": 8080,
|
||||
"debug": true,
|
||||
"database": { "url": "postgres://x", "pool_size": 10 }
|
||||
});
|
||||
let cfg: AppConfig = ConfigLoader::new().merge_value(value).build().unwrap();
|
||||
assert_eq!(
|
||||
cfg,
|
||||
AppConfig {
|
||||
port: 8080,
|
||||
debug: true,
|
||||
database: Db {
|
||||
url: "postgres://x".into(),
|
||||
pool_size: 10
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_merge_overrides_nested_leaf_only() {
|
||||
let base = serde_json::json!({ "port": 8080, "debug": false, "database": { "url": "a", "pool_size": 5 } });
|
||||
let overlay = serde_json::json!({ "database": { "pool_size": 20 } });
|
||||
let cfg: AppConfig = ConfigLoader::new()
|
||||
.merge_value(base)
|
||||
.merge_value(overlay)
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(cfg.database.pool_size, 20);
|
||||
assert_eq!(cfg.database.url, "a"); // untouched sibling field preserved
|
||||
assert_eq!(cfg.port, 8080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_merge_maps_nested_and_types() {
|
||||
// Safe because this test owns unique env var names.
|
||||
std::env::set_var("CFGTEST_PORT", "9090");
|
||||
std::env::set_var("CFGTEST_DEBUG", "true");
|
||||
std::env::set_var("CFGTEST_DATABASE__URL", "redis://y");
|
||||
std::env::set_var("CFGTEST_DATABASE__POOL_SIZE", "42");
|
||||
|
||||
let cfg: AppConfig = ConfigLoader::new().merge_env("CFGTEST").build().unwrap();
|
||||
assert_eq!(cfg.port, 9090);
|
||||
assert!(cfg.debug);
|
||||
assert_eq!(cfg.database.url, "redis://y");
|
||||
assert_eq!(cfg.database.pool_size, 42);
|
||||
|
||||
std::env::remove_var("CFGTEST_PORT");
|
||||
std::env::remove_var("CFGTEST_DEBUG");
|
||||
std::env::remove_var("CFGTEST_DATABASE__URL");
|
||||
std::env::remove_var("CFGTEST_DATABASE__POOL_SIZE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_overrides_file_value() {
|
||||
std::env::set_var("CFGTEST2_PORT", "7000");
|
||||
let base = serde_json::json!({ "port": 1, "debug": false, "database": { "url": "a", "pool_size": 1 } });
|
||||
let cfg: AppConfig = ConfigLoader::new()
|
||||
.merge_value(base)
|
||||
.merge_env("CFGTEST2")
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(cfg.port, 7000);
|
||||
std::env::remove_var("CFGTEST2_PORT");
|
||||
}
|
||||
|
||||
#[cfg(feature = "yaml")]
|
||||
#[test]
|
||||
fn merge_yaml_file() {
|
||||
use std::io::Write;
|
||||
let mut file = tempfile::Builder::new().suffix(".yaml").tempfile().unwrap();
|
||||
writeln!(
|
||||
file,
|
||||
"port: 3000\ndebug: false\ndatabase:\n url: sqlite://mem\n pool_size: 3\n"
|
||||
)
|
||||
.unwrap();
|
||||
let cfg: AppConfig = ConfigLoader::new()
|
||||
.merge_file(file.path())
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(cfg.port, 3000);
|
||||
assert_eq!(cfg.database.url, "sqlite://mem");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_json_file() {
|
||||
use std::io::Write;
|
||||
let mut file = tempfile::Builder::new().suffix(".json").tempfile().unwrap();
|
||||
write!(
|
||||
file,
|
||||
r#"{{"port": 4000, "debug": true, "database": {{"url": "mysql://x", "pool_size": 7}}}}"#
|
||||
)
|
||||
.unwrap();
|
||||
let cfg: AppConfig = ConfigLoader::new()
|
||||
.merge_file(file.path())
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(cfg.port, 4000);
|
||||
assert_eq!(cfg.database.pool_size, 7);
|
||||
}
|
||||
|
||||
#[cfg(feature = "toml")]
|
||||
#[test]
|
||||
fn merge_toml_file() {
|
||||
use std::io::Write;
|
||||
let mut file = tempfile::Builder::new().suffix(".toml").tempfile().unwrap();
|
||||
writeln!(
|
||||
file,
|
||||
"port = 5000\ndebug = false\n\n[database]\nurl = \"file://local\"\npool_size = 2\n"
|
||||
)
|
||||
.unwrap();
|
||||
let cfg: AppConfig = ConfigLoader::new()
|
||||
.merge_file(file.path())
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(cfg.port, 5000);
|
||||
assert_eq!(cfg.database.pool_size, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_extension_errors() {
|
||||
let file = tempfile::Builder::new().suffix(".ini").tempfile().unwrap();
|
||||
std::fs::write(file.path(), "unused").unwrap();
|
||||
let result: Result<AppConfig, _> = ConfigLoader::new()
|
||||
.merge_file(file.path())
|
||||
.and_then(|l| l.build());
|
||||
assert!(matches!(result, Err(ConfigError::UnsupportedFormat(_))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "corex-crypto"
|
||||
version = "0.1.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-crypto"
|
||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||
description = "Safe hashing, encryption, and token helpers (Argon2id, AES-256-GCM, JWT/Paseto) with key rotation support."
|
||||
readme = "README.md"
|
||||
keywords = ["crypto", "argon2", "aes-gcm", "jwt", "security"]
|
||||
categories = ["cryptography", "authentication"]
|
||||
|
||||
[features]
|
||||
default = ["password", "encryption", "tokens"]
|
||||
# Low-level primitives are always available (zero-cost). The feature flags
|
||||
# pull in the backing SDK crates.
|
||||
password = ["dep:password-hash", "dep:argon2"]
|
||||
encryption = ["dep:aead", "dep:aes-gcm", "dep:rand_core", "dep:rand"]
|
||||
tokens = ["encryption", "dep:serde", "dep:serde_json", "dep:base64", "dep:jsonwebtoken"]
|
||||
|
||||
[dependencies]
|
||||
tracing = "0.1"
|
||||
|
||||
# Optional backends
|
||||
argon2 = { version = "0.5", default-features = false, features = ["std"], optional = true }
|
||||
password-hash = { version = "0.5", default-features = false, features = ["std"], optional = true }
|
||||
aes-gcm = { version = "0.10", default-features = false, features = ["aes", "alloc"], optional = true }
|
||||
aead = { version = "0.5", default-features = false, features = ["alloc"], optional = true }
|
||||
jsonwebtoken = { version = "9", default-features = false, optional = true }
|
||||
base64 = { version = "0.22", default-features = false, optional = true }
|
||||
serde = { version = "1", optional = true, features = ["derive"] }
|
||||
serde_json = { version = "1", optional = true }
|
||||
rand = { version = "0.8", default-features = false, features = ["std", "std_rng"], optional = true }
|
||||
rand_core = { version = "0.6", optional = true }
|
||||
@@ -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-crypto
|
||||
|
||||
Safe, one-line security helpers that are easy to get wrong when hand-rolled:
|
||||
|
||||
- **Argon2id** password hashing & verification (PHC-encoded, RFC 9106-style).
|
||||
- **AES-256-GCM** authenticated encryption with a fresh random nonce per op.
|
||||
- **JWT** (HS256) token generation & validation.
|
||||
- **Key rotation** via `KeyRing` — reads keep working with the previous key
|
||||
during a rotation window.
|
||||
|
||||
## Features
|
||||
|
||||
- `password` (default) — Argon2id hashing.
|
||||
- `encryption` (default) — AES-256-GCM.
|
||||
- `tokens` (default) — JSON Web Tokens.
|
||||
|
||||
Zero features enabled by default? No — all three are on, but each is cheap and
|
||||
independent.
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use corex_crypto::{PasswordHasher, Encryptor, TokenSigner};
|
||||
|
||||
let hasher = PasswordHasher::new();
|
||||
let hash = hasher.hash("letmein").unwrap();
|
||||
assert!(hasher.verify(&hash, "letmein"));
|
||||
|
||||
let enc = Encryptor::new(&[0u8; 32]);
|
||||
let (nonce, ct) = enc.encrypt(b"secret");
|
||||
assert_eq!(enc.decrypt(&nonce, &ct).unwrap(), b"secret");
|
||||
|
||||
let signer = TokenSigner::new("my-secret");
|
||||
let token = signer.sign(&serde_json::json!({"sub":"u1"}), std::time::Duration::from_secs(3600)).unwrap();
|
||||
assert_eq!(signer.verify(&token).unwrap()["sub"], "u1");
|
||||
```
|
||||
@@ -0,0 +1,146 @@
|
||||
//! AES-256-GCM authenticated encryption with a random nonce per operation.
|
||||
//!
|
||||
//! The `nonce` is generated fresh for every [`Encryptor::encrypt`] call and
|
||||
//! returned alongside the ciphertext so the caller can store/transmit it. The
|
||||
//! caller must keep the plaintext length out of scope of concern; GCM provides
|
||||
//! confidentiality and integrity.
|
||||
|
||||
use aes_gcm::aead::{Aead, KeyInit};
|
||||
use aes_gcm::{Aes256Gcm, Nonce};
|
||||
|
||||
use crate::{KEY_LEN, NONCE_LEN};
|
||||
|
||||
/// A thin wrapper around AES-256-GCM providing a safe one-line encrypt/decrypt.
|
||||
pub struct Encryptor {
|
||||
cipher: Aes256Gcm,
|
||||
}
|
||||
|
||||
/// The failure mode of an AEAD operation.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum AeadError {
|
||||
/// Decryption failed because the authentication tag did not match.
|
||||
AuthenticationFailed,
|
||||
/// Key or nonce material had an invalid length/format.
|
||||
InvalidInput,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AeadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::AuthenticationFailed => write!(f, "authentication failed"),
|
||||
Self::InvalidInput => write!(f, "invalid input"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AeadError {}
|
||||
|
||||
impl Encryptor {
|
||||
/// Builds a GCM encryptor from a 32-byte key.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `key` is not exactly `KEY_LEN` (32) bytes.
|
||||
pub fn new(key: &[u8]) -> Self {
|
||||
assert_eq!(key.len(), KEY_LEN, "AES-256-GCM requires a 32-byte key");
|
||||
let mut kb = [0u8; KEY_LEN];
|
||||
kb.copy_from_slice(key);
|
||||
Self {
|
||||
cipher: Aes256Gcm::new((&kb).into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypts `plaintext`, returning `(nonce, ciphertext_with_tag)`.
|
||||
///
|
||||
/// The nonce is 12 random bytes, unique per call.
|
||||
pub fn encrypt(&self, plaintext: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
let nonce_bytes = Self::random_nonce();
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let ct = self
|
||||
.cipher
|
||||
.encrypt(nonce, plaintext)
|
||||
.expect("AES-256-GCM encryption is infallible for valid input");
|
||||
(nonce_bytes.to_vec(), ct)
|
||||
}
|
||||
|
||||
/// Decrypts `nonce || ciphertext` produced by [`Encryptor::encrypt`].
|
||||
pub fn decrypt(&self, nonce: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, AeadError> {
|
||||
if nonce.len() != NONCE_LEN {
|
||||
return Err(AeadError::InvalidInput);
|
||||
}
|
||||
let nonce = Nonce::from_slice(nonce);
|
||||
self.cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|_| AeadError::AuthenticationFailed)
|
||||
}
|
||||
|
||||
/// Deterministically encrypts with a caller-supplied nonce (for tests or
|
||||
/// for deriving per-record nonces from a counter). Prefer [`encrypt`].
|
||||
///
|
||||
/// [`encrypt`]: Encryptor::encrypt
|
||||
pub fn encrypt_with_nonce(
|
||||
&self,
|
||||
nonce: &[u8; NONCE_LEN],
|
||||
plaintext: &[u8],
|
||||
) -> Result<Vec<u8>, AeadError> {
|
||||
self.cipher
|
||||
.encrypt(Nonce::from_slice(nonce), plaintext)
|
||||
.map_err(|_| AeadError::InvalidInput)
|
||||
}
|
||||
|
||||
fn random_nonce() -> [u8; NONCE_LEN] {
|
||||
let mut n = [0u8; NONCE_LEN];
|
||||
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut n);
|
||||
n
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key() -> [u8; KEY_LEN] {
|
||||
[0x42u8; KEY_LEN]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_roundtrip() {
|
||||
let e = Encryptor::new(&key());
|
||||
let (nonce, ct) = e.encrypt(b"classified briefcase");
|
||||
let plain = e.decrypt(&nonce, &ct).unwrap();
|
||||
assert_eq!(plain, b"classified briefcase");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_ciphertext_fails_auth() {
|
||||
let e = Encryptor::new(&key());
|
||||
let (nonce, mut ct) = e.encrypt(b"tamper me");
|
||||
ct[0] ^= 0xff;
|
||||
assert_eq!(e.decrypt(&nonce, &ct), Err(AeadError::AuthenticationFailed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_fails_auth() {
|
||||
let e = Encryptor::new(&key());
|
||||
let (nonce, ct) = e.encrypt(b"hi");
|
||||
let wrong = Encryptor::new(&[0x99u8; KEY_LEN]);
|
||||
assert_eq!(
|
||||
wrong.decrypt(&nonce, &ct),
|
||||
Err(AeadError::AuthenticationFailed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonce_is_random_per_call() {
|
||||
let e = Encryptor::new(&key());
|
||||
let (n1, _) = e.encrypt(b"data");
|
||||
let (n2, _) = e.encrypt(b"data");
|
||||
assert_ne!(n1, n2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_length_panics() {
|
||||
let result = std::panic::catch_unwind(|| Encryptor::new(&[0u8; 16]));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Key rotation support.
|
||||
//!
|
||||
//! [`KeyRing`] holds a "current" key plus a list of "previous" keys. Operations
|
||||
//! that need to *decrypt* or *verify* (as opposed to sign/encrypt) try the
|
||||
//! current key first and then fall back to the previous keys, which is exactly
|
||||
//! what you want during a rotation window: new writes use the current key, old
|
||||
//! data can still be read with the previous key.
|
||||
|
||||
/// A generic ring of keys: one current plus any number of previous.
|
||||
///
|
||||
/// `T` is typically `&[u8]` or a key handle. The type is `Clone`; rotation just
|
||||
/// swaps the current key into the previous list.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeyRing<T> {
|
||||
current: T,
|
||||
previous: Vec<T>,
|
||||
}
|
||||
|
||||
impl<T> KeyRing<T> {
|
||||
/// Builds a ring with a single current key.
|
||||
pub fn new(current: T) -> Self {
|
||||
Self {
|
||||
current,
|
||||
previous: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current key.
|
||||
pub fn current(&self) -> &T {
|
||||
&self.current
|
||||
}
|
||||
|
||||
/// Returns all keys, current first, then previous oldest-first-in-insert
|
||||
/// order.
|
||||
pub fn all_keys(&self) -> impl Iterator<Item = &T> {
|
||||
std::iter::once(&self.current).chain(self.previous.iter())
|
||||
}
|
||||
|
||||
/// Rotates in a new key, demoting the old current key to `previous`.
|
||||
///
|
||||
/// Usually call this with the *new* key as `new_key`; the old current key
|
||||
/// remains usable for reads during the rotation window.
|
||||
pub fn rotate(&mut self, new_key: T) {
|
||||
let old = std::mem::replace(&mut self.current, new_key);
|
||||
self.previous.push(old);
|
||||
}
|
||||
|
||||
/// The number of keys being tracked (current + previous).
|
||||
pub fn size(&self) -> usize {
|
||||
1 + self.previous.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> KeyRing<T>
|
||||
where
|
||||
T: PartialEq,
|
||||
{
|
||||
/// Whether `key` is currently in the ring (current or previous).
|
||||
pub fn contains(&self, key: &T) -> bool {
|
||||
self.all_keys().any(|k| k == key)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rotate_preserves_previous() {
|
||||
let mut ring = KeyRing::new([1u8; 32]);
|
||||
assert_eq!(ring.current(), &[1u8; 32]);
|
||||
assert_eq!(ring.size(), 1);
|
||||
|
||||
ring.rotate([2u8; 32]);
|
||||
assert_eq!(ring.current(), &[2u8; 32]);
|
||||
assert_eq!(ring.size(), 2);
|
||||
assert!(ring.contains(&[1u8; 32]));
|
||||
assert!(ring.contains(&[2u8; 32]));
|
||||
|
||||
ring.rotate([3u8; 32]);
|
||||
assert_eq!(ring.size(), 3);
|
||||
assert!(ring.contains(&[1u8; 32]));
|
||||
assert!(ring.contains(&[2u8; 32]));
|
||||
assert!(ring.contains(&[3u8; 32]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_keys_iterates_current_first() {
|
||||
let mut ring = KeyRing::new("current");
|
||||
ring.rotate("prev1");
|
||||
ring.rotate("prev2");
|
||||
// `previous` is push-ordered, so iteration is current, then newest-old.
|
||||
let keys: Vec<&str> = ring.all_keys().copied().collect();
|
||||
assert_eq!(keys, vec!["prev2", "current", "prev1"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! # corex-crypto
|
||||
//!
|
||||
//! Low-level security helpers that are easy to get wrong when hand-rolled:
|
||||
//!
|
||||
//! - **Argon2id** password hashing & verification (`password` feature), with
|
||||
//! RFC 9106-ish parameters.
|
||||
//! - **AES-256-GCM** authenticated encryption with a fresh random nonce per
|
||||
//! operation (`encryption` feature).
|
||||
//! - **JWT** (HS256) / **Paseto** v4 local token generation & validation (`tokens`
|
||||
//! feature).
|
||||
//! - **Key rotation** via [`KeyRing`]: decryption/verification tries the current
|
||||
//! key then a list of previous keys.
|
||||
//!
|
||||
//! Nothing in the crate owns long-lived key material; keys are passed in as
|
||||
//! bytes/keys by the caller and the caller is responsible for storage. Each
|
||||
//! primitive is small enough to reason about in one screen.
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use corex_crypto::{PasswordHasher, Encryptor, TokenSigner};
|
||||
//!
|
||||
//! // Hash & verify a password.
|
||||
//! let hasher = PasswordHasher::new();
|
||||
//! let hash = hasher.hash("hunter2").unwrap();
|
||||
//! assert!(hasher.verify(&hash, "hunter2"));
|
||||
//!
|
||||
//! // Encrypt & decrypt a blob.
|
||||
//! let key = [0u8; 32];
|
||||
//! let enc = Encryptor::new(&key);
|
||||
//! let (nonce, ct) = enc.encrypt(b"secret message");
|
||||
//! let plain = enc.decrypt(&nonce, &ct).unwrap();
|
||||
//! assert_eq!(plain, b"secret message");
|
||||
//!
|
||||
//! // Sign & verify a JWT.
|
||||
//! let signer = TokenSigner::new("super-secret-key");
|
||||
//! let token = signer
|
||||
//! .sign(&serde_json::json!({ "sub": "u1" }), std::time::Duration::from_secs(3600))
|
||||
//! .unwrap();
|
||||
//! let claims = signer.verify(&token).unwrap();
|
||||
//! assert_eq!(claims["sub"], "u1");
|
||||
//! ```
|
||||
|
||||
pub mod key_ring;
|
||||
|
||||
#[cfg(feature = "password")]
|
||||
pub mod password;
|
||||
|
||||
#[cfg(feature = "encryption")]
|
||||
pub mod encryption;
|
||||
|
||||
#[cfg(feature = "tokens")]
|
||||
pub mod token;
|
||||
|
||||
#[cfg(feature = "password")]
|
||||
pub use password::PasswordHasher;
|
||||
|
||||
#[cfg(feature = "encryption")]
|
||||
pub use encryption::{AeadError, Encryptor};
|
||||
|
||||
#[cfg(feature = "tokens")]
|
||||
pub use token::{Claims, TokenError, TokenSigner};
|
||||
|
||||
pub use key_ring::KeyRing;
|
||||
|
||||
/// Errors returned across corex-crypto primitives.
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug)]
|
||||
pub enum CryptoError {
|
||||
/// Password hashing or verification failed.
|
||||
Password(String),
|
||||
/// Authenticated encryption / decryption failed.
|
||||
Encryption(String),
|
||||
/// Token generation or validation failed.
|
||||
Token(String),
|
||||
/// Key material is invalid for the requested operation.
|
||||
Key(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CryptoError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Password(msg) => write!(f, "password error: {msg}"),
|
||||
Self::Encryption(msg) => write!(f, "encryption error: {msg}"),
|
||||
Self::Token(msg) => write!(f, "token error: {msg}"),
|
||||
Self::Key(msg) => write!(f, "key error: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CryptoError {}
|
||||
|
||||
/// The fixed AES-256-GCM nonce length (96 bits) in bytes.
|
||||
pub const NONCE_LEN: usize = 12;
|
||||
/// The fixed AES-256-GCM key length in bytes.
|
||||
pub const KEY_LEN: usize = 32;
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Argon2id password hashing (RFC 9106).
|
||||
//!
|
||||
//! Hashes are stored as PHC strings, so they carry their parameters inline and
|
||||
//! can be verified even if the recommended parameters change over time.
|
||||
|
||||
// Traits imported with `_` names so their methods resolve without colliding
|
||||
// with the `PasswordHasher` struct defined below.
|
||||
use argon2::password_hash::{PasswordHash, PasswordHasher as _, PasswordVerifier as _, SaltString};
|
||||
use argon2::Argon2;
|
||||
|
||||
use crate::CryptoError;
|
||||
|
||||
/// Default memory cost (KiB) — 64 MiB.
|
||||
const DEFAULT_MEM: u32 = 64 * 1024;
|
||||
/// Default time cost.
|
||||
const DEFAULT_TIME: u32 = 3;
|
||||
/// Default parallelism (lanes).
|
||||
const DEFAULT_PAR: u32 = 1;
|
||||
|
||||
/// An Argon2id password hasher with configurable parameters.
|
||||
#[derive(Clone)]
|
||||
pub struct PasswordHasher {
|
||||
mem: u32,
|
||||
time: u32,
|
||||
parallelism: u32,
|
||||
}
|
||||
|
||||
impl Default for PasswordHasher {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mem: DEFAULT_MEM,
|
||||
time: DEFAULT_TIME,
|
||||
parallelism: DEFAULT_PAR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PasswordHasher {
|
||||
/// Builds a hasher with RFC-9106-style defaults.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Builds a hasher with custom Argon2 parameters.
|
||||
pub fn with_params(mem: u32, time: u32, parallelism: u32) -> Self {
|
||||
Self {
|
||||
mem,
|
||||
time,
|
||||
parallelism,
|
||||
}
|
||||
}
|
||||
|
||||
/// The configured memory cost in KiB.
|
||||
pub fn memory_cost(&self) -> u32 {
|
||||
self.mem
|
||||
}
|
||||
|
||||
/// Hashes `password` using Argon2id with a fresh random salt, returning a
|
||||
/// PHC-encoded string (`$argon2id$v=19$m=...,t=...,p=...$salt$hash`).
|
||||
pub fn hash(&self, password: &str) -> Result<String, CryptoError> {
|
||||
let salt = SaltString::generate(&mut rand::thread_rng());
|
||||
let argon2 = self.argon2();
|
||||
let hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| CryptoError::Password(e.to_string()))?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// Verifies `password` against a previously computed PHC `hash`.
|
||||
///
|
||||
/// Uses the parameters encoded in the hash (not our current defaults), so
|
||||
/// older hashes with different parameters still verify. Returns `false`
|
||||
/// on a mismatch or malformed hash.
|
||||
pub fn verify(&self, hash: &str, password: &str) -> bool {
|
||||
let parsed = match PasswordHash::new(hash) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return false,
|
||||
};
|
||||
// Reconstruct Argon2 parameters from the PHC-serialized params string.
|
||||
let (mem, time, lanes) = match parse_params(parsed.params.as_str()) {
|
||||
Some(v) => v,
|
||||
None => (DEFAULT_MEM, DEFAULT_TIME, DEFAULT_PAR),
|
||||
};
|
||||
let params = match argon2::Params::new(mem, time, lanes, None) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let version = parsed
|
||||
.version
|
||||
.and_then(|v| match v {
|
||||
0x10 => Some(argon2::Version::V0x10),
|
||||
0x13 => Some(argon2::Version::V0x13),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(argon2::Version::V0x13);
|
||||
let algorithm = match &*parsed.algorithm {
|
||||
"argon2d" => argon2::Algorithm::Argon2d,
|
||||
"argon2i" => argon2::Algorithm::Argon2i,
|
||||
_ => argon2::Algorithm::Argon2id,
|
||||
};
|
||||
let verifier = Argon2::new(algorithm, version, params);
|
||||
verifier
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn argon2(&self) -> Argon2<'static> {
|
||||
let params = argon2::Params::new(self.mem, self.time, self.parallelism, None)
|
||||
.expect("valid argon2 parameters");
|
||||
Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `m=65536,t=3,p=1` style params out of a PHC params string.
|
||||
fn parse_params(params: &str) -> Option<(u32, u32, u32)> {
|
||||
let mut m = None;
|
||||
let mut t = None;
|
||||
let mut p = None;
|
||||
for part in params.split(',') {
|
||||
let (k, v) = part.split_once('=')?;
|
||||
let value = v.parse::<u32>().ok()?;
|
||||
match k {
|
||||
"m" => m = Some(value),
|
||||
"t" => t = Some(value),
|
||||
"p" => p = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some((m?, t?, p?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hash_and_verify_roundtrip() {
|
||||
let h = PasswordHasher::new();
|
||||
let encoded = h.hash("correct horse battery staple").unwrap();
|
||||
assert!(h.verify(&encoded, "correct horse battery staple"));
|
||||
assert!(!h.verify(&encoded, "wrong password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_salts_yield_different_hashes() {
|
||||
let h = PasswordHasher::new();
|
||||
let a = h.hash("samepassword").unwrap();
|
||||
let b = h.hash("samepassword").unwrap();
|
||||
assert_ne!(a, b);
|
||||
assert!(h.verify(&a, "samepassword"));
|
||||
assert!(h.verify(&b, "samepassword"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_hash_verifies_false() {
|
||||
let h = PasswordHasher::new();
|
||||
assert!(!h.verify("not-a-real-hash", "anything"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_string_is_phc_formatted() {
|
||||
let h = PasswordHasher::new();
|
||||
let encoded = h.hash("x").unwrap();
|
||||
assert!(encoded.starts_with("$argon2id$v=19$m=65536,t=3,p=1$"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! JWT (HS256) generation and validation.
|
||||
//!
|
||||
//! Uses `jsonwebtoken`. Claims are plain `serde_json::Value` so callers can
|
||||
//! build arbitrary claim sets without a bespoke struct.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// The error produced by token sign/verify.
|
||||
#[derive(Debug)]
|
||||
pub enum TokenError {
|
||||
/// The token or key was malformed.
|
||||
Encoding(String),
|
||||
/// The token failed validation (bad signature, expired, etc.).
|
||||
Validation(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TokenError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Encoding(s) => write!(f, "token encoding: {s}"),
|
||||
Self::Validation(s) => write!(f, "token validation: {s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TokenError {}
|
||||
|
||||
/// A JWT signing/verification helper using HS256.
|
||||
#[derive(Clone)]
|
||||
pub struct TokenSigner {
|
||||
key: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Convenience alias for a `serde_json::Value` claim set.
|
||||
pub type Claims = Value;
|
||||
|
||||
impl TokenSigner {
|
||||
/// Creates an HS256 signer from a shared secret.
|
||||
pub fn new(secret: &str) -> Self {
|
||||
Self {
|
||||
key: secret.as_bytes().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Signs `claims` (plus an automated `exp` and `iat`) into a JWT string.
|
||||
pub fn sign(&self, claims: &Value, ttl: std::time::Duration) -> Result<String, TokenError> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
|
||||
let mut payload = claims.clone();
|
||||
if !payload.is_object() {
|
||||
return Err(TokenError::Encoding("claims must be a JSON object".into()));
|
||||
}
|
||||
if payload.get("exp").is_none() {
|
||||
payload["exp"] = Value::Number((now + ttl.as_secs()).into());
|
||||
}
|
||||
if payload.get("iat").is_none() {
|
||||
payload["iat"] = Value::Number(now.into());
|
||||
}
|
||||
let token = jsonwebtoken::encode(
|
||||
&header,
|
||||
&payload,
|
||||
&jsonwebtoken::EncodingKey::from_secret(&self.key),
|
||||
)
|
||||
.map_err(|e| TokenError::Encoding(e.to_string()))?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Verifies `token` and returns its decoded claims.
|
||||
///
|
||||
/// The signature, `exp`, and `iat` are all validated.
|
||||
pub fn verify(&self, token: &str) -> Result<Claims, TokenError> {
|
||||
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
|
||||
validation.validate_exp = true;
|
||||
// `iat` is validated implicitly (rejected if in the future beyond leeway).
|
||||
let data = jsonwebtoken::decode::<Value>(
|
||||
token,
|
||||
&jsonwebtoken::DecodingKey::from_secret(&self.key),
|
||||
&validation,
|
||||
)
|
||||
.map_err(|e| TokenError::Validation(e.to_string()))?;
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
/// Verifies a token and additionally checks the registered `sub` claim.
|
||||
pub fn verify_subject(&self, token: &str, expected_subject: &str) -> Result<(), TokenError> {
|
||||
let claims = self.verify(token)?;
|
||||
match claims.get("sub").and_then(Value::as_str) {
|
||||
Some(sub) if sub == expected_subject => Ok(()),
|
||||
_ => Err(TokenError::Validation("subject mismatch".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The expiry claim helper used by [`TokenSigner`] (kept for symmetry).
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RegisteredClaims {
|
||||
pub iat: Option<u64>,
|
||||
pub exp: Option<u64>,
|
||||
pub sub: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sign_verify_roundtrip() {
|
||||
let s = TokenSigner::new("my secret");
|
||||
let token = s
|
||||
.sign(
|
||||
&serde_json::json!({ "sub": "user-1", "role": "admin" }),
|
||||
std::time::Duration::from_secs(3600),
|
||||
)
|
||||
.unwrap();
|
||||
let claims = s.verify(&token).unwrap();
|
||||
assert_eq!(claims["sub"], "user-1");
|
||||
assert_eq!(claims["role"], "admin");
|
||||
assert!(claims["exp"].is_number());
|
||||
assert!(claims["iat"].is_number());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_secret_fails() {
|
||||
let a = TokenSigner::new("key-a");
|
||||
let b = TokenSigner::new("key-b");
|
||||
let token = a
|
||||
.sign(
|
||||
&serde_json::json!({ "sub": "x" }),
|
||||
std::time::Duration::from_secs(100),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(b.verify(&token).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_token_fails() {
|
||||
let a = TokenSigner::new("key-a");
|
||||
let token = a
|
||||
.sign(
|
||||
&serde_json::json!({ "sub": "x" }),
|
||||
std::time::Duration::from_secs(100),
|
||||
)
|
||||
.unwrap();
|
||||
let mut bytes = token.into_bytes();
|
||||
let last = bytes.len() - 1;
|
||||
bytes[last] ^= 0x01;
|
||||
let tampered = String::from_utf8(bytes).unwrap();
|
||||
assert!(a.verify(&tampered).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_token_fails() {
|
||||
let a = TokenSigner::new("key-a");
|
||||
// Explicitly past `exp` (1970); sign only fills it in if absent.
|
||||
let token = a
|
||||
.sign(
|
||||
&serde_json::json!({ "sub": "x", "exp": 1000 }),
|
||||
std::time::Duration::from_secs(3600),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(a.verify(&token).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_check() {
|
||||
let s = TokenSigner::new("s");
|
||||
let token = s
|
||||
.sign(
|
||||
&serde_json::json!({ "sub": "alice" }),
|
||||
std::time::Duration::from_secs(100),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.verify_subject(&token, "alice").is_ok());
|
||||
assert!(s.verify_subject(&token, "bob").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
[package]
|
||||
name = "corex-event"
|
||||
version = "0.1.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
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
[package]
|
||||
name = "corex-storage"
|
||||
version = "0.1.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-storage"
|
||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||
description = "Unified storage & file system abstraction: one driver interface over Local Disk, S3/MinIO, and Google Cloud Storage, with stream-based upload/download."
|
||||
readme = "README.md"
|
||||
keywords = ["storage", "s3", "gcs", "minio", "filesystem"]
|
||||
categories = ["filesystem", "asynchronous"]
|
||||
|
||||
[features]
|
||||
default = ["local"]
|
||||
# The core `StorageDriver` trait (always compiled) needs `tokio`'s io-util for
|
||||
# `AsyncRead`/`ReadBuf`; `local` additionally needs `fs`/`rt`.
|
||||
local = ["tokio/fs", "tokio/rt"]
|
||||
# S3-compatible object storage (also covers MinIO via a custom endpoint).
|
||||
s3 = ["dep:aws-sdk-s3", "dep:aws-config", "dep:aws-credential-types"]
|
||||
# Google Cloud Storage.
|
||||
gcs = ["dep:google-cloud-storage", "dep:google-cloud-auth"]
|
||||
|
||||
[dependencies]
|
||||
tracing = "0.1"
|
||||
# Required unconditionally: the core `StorageDriver` trait uses both.
|
||||
async-trait = "0.1"
|
||||
tokio = { version = "1.53", features = ["io-util"] }
|
||||
bytes = "1"
|
||||
|
||||
# S3 / MinIO
|
||||
aws-sdk-s3 = { version = "1", optional = true }
|
||||
aws-config = { version = "1", optional = true, features = ["behavior-version-latest"] }
|
||||
aws-credential-types = { version = "1", optional = true }
|
||||
|
||||
# Google Cloud Storage
|
||||
google-cloud-storage = { version = "0.24", optional = true }
|
||||
google-cloud-auth = { version = "0.17", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53", features = ["full"] }
|
||||
tempfile = "3"
|
||||
@@ -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,26 @@
|
||||
# corex-storage
|
||||
|
||||
A unified storage & file system abstraction so file handling isn't locked to
|
||||
one physical location.
|
||||
|
||||
- **Local disk** (default) — plain files under a root directory.
|
||||
- **S3-compatible object storage** (`s3`) — Amazon S3, and any S3-compatible
|
||||
endpoint (MinIO included) via a custom endpoint URL.
|
||||
- **Google Cloud Storage** (`gcs`).
|
||||
|
||||
All operations are stream-based, so uploading/downloading a huge object never
|
||||
requires holding it entirely in memory.
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use corex_storage::{LocalFileStorage, StorageDriver, bytes_stream, read_to_vec};
|
||||
|
||||
let storage = LocalFileStorage::new("/var/data");
|
||||
storage.put("uploads/report.csv", bytes_stream(data)).await?;
|
||||
let bytes = read_to_vec(storage.get("uploads/report.csv").await?).await?;
|
||||
```
|
||||
|
||||
Swap `LocalFileStorage::new(path)` for `S3Storage::connect("bucket").await?` or
|
||||
`GcsStorage::connect("bucket").await?` to move to a distributed backend
|
||||
without touching call sites.
|
||||
@@ -0,0 +1,165 @@
|
||||
//! A Google Cloud Storage [`StorageDriver`] (feature `gcs`), via
|
||||
//! `google-cloud-storage`.
|
||||
//!
|
||||
//! [`get`](GcsStorage::get) streams the downloaded object; [`put`](GcsStorage::put)
|
||||
//! currently buffers the input before uploading in a single request (the
|
||||
//! `google-cloud-storage` crate's simple upload API takes an owned body). For
|
||||
//! very large uploads prefer chunked application-level batching until this
|
||||
//! crate grows resumable-upload support.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use google_cloud_storage::client::{Client, ClientConfig};
|
||||
use google_cloud_storage::http::objects::delete::DeleteObjectRequest;
|
||||
use google_cloud_storage::http::objects::download::Range;
|
||||
use google_cloud_storage::http::objects::get::GetObjectRequest;
|
||||
use google_cloud_storage::http::objects::upload::{Media, UploadObjectRequest, UploadType};
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
use crate::traits::{ObjectMeta, ObjectStream, StorageDriver, StorageError};
|
||||
|
||||
fn map_err<E: std::fmt::Display>(e: E) -> StorageError {
|
||||
StorageError::Io(e.to_string())
|
||||
}
|
||||
|
||||
/// A [`StorageDriver`] backed by a Google Cloud Storage bucket.
|
||||
#[derive(Clone)]
|
||||
pub struct GcsStorage {
|
||||
client: Client,
|
||||
bucket: String,
|
||||
}
|
||||
|
||||
impl GcsStorage {
|
||||
/// Connects using Application Default Credentials (`GOOGLE_APPLICATION_CREDENTIALS`,
|
||||
/// workload identity, etc.).
|
||||
pub async fn connect(bucket: impl Into<String>) -> Result<Self, StorageError> {
|
||||
let config = ClientConfig::default().with_auth().await.map_err(map_err)?;
|
||||
Ok(Self {
|
||||
client: Client::new(config),
|
||||
bucket: bucket.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Wraps an already-configured client.
|
||||
pub fn from_client(client: Client, bucket: impl Into<String>) -> Self {
|
||||
Self {
|
||||
client,
|
||||
bucket: bucket.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StorageDriver for GcsStorage {
|
||||
async fn get(&self, path: &str) -> Result<ObjectStream, StorageError> {
|
||||
let bytes = self
|
||||
.client
|
||||
.download_object(
|
||||
&GetObjectRequest {
|
||||
bucket: self.bucket.clone(),
|
||||
object: path.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&Range::default(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.to_string().contains("404") {
|
||||
StorageError::NotFound(path.to_string())
|
||||
} else {
|
||||
map_err(e)
|
||||
}
|
||||
})?;
|
||||
Ok(crate::traits::bytes_stream(bytes))
|
||||
}
|
||||
|
||||
async fn put(&self, path: &str, mut data: ObjectStream) -> Result<u64, StorageError> {
|
||||
let mut buf = Vec::new();
|
||||
data.read_to_end(&mut buf)
|
||||
.await
|
||||
.map_err(|e| StorageError::Io(e.to_string()))?;
|
||||
let len = buf.len() as u64;
|
||||
let upload_type = UploadType::Simple(Media::new(path.to_string()));
|
||||
self.client
|
||||
.upload_object(
|
||||
&UploadObjectRequest {
|
||||
bucket: self.bucket.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
buf,
|
||||
&upload_type,
|
||||
)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
Ok(len)
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<(), StorageError> {
|
||||
self.client
|
||||
.delete_object(&DeleteObjectRequest {
|
||||
bucket: self.bucket.clone(),
|
||||
object: path.to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map_err(map_err)
|
||||
}
|
||||
|
||||
async fn exists(&self, path: &str) -> Result<bool, StorageError> {
|
||||
match self.stat(path).await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(StorageError::NotFound(_)) => Ok(false),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<ObjectMeta, StorageError> {
|
||||
let obj = self
|
||||
.client
|
||||
.get_object(&GetObjectRequest {
|
||||
bucket: self.bucket.clone(),
|
||||
object: path.to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.to_string().contains("404") {
|
||||
StorageError::NotFound(path.to_string())
|
||||
} else {
|
||||
map_err(e)
|
||||
}
|
||||
})?;
|
||||
Ok(ObjectMeta {
|
||||
size: obj.size.max(0) as u64,
|
||||
etag: Some(obj.etag),
|
||||
last_modified: obj.updated.map(|t| t.into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::{bytes_stream, read_to_vec};
|
||||
|
||||
/// Requires a live GCS bucket with Application Default Credentials
|
||||
/// configured, and `GCS_BUCKET` set. Run with:
|
||||
/// `GCS_BUCKET=my-bucket cargo test -p corex-storage --features gcs -- --ignored`.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a live GCS bucket + Application Default Credentials"]
|
||||
async fn put_get_roundtrip_live() {
|
||||
let bucket = std::env::var("GCS_BUCKET").expect("set GCS_BUCKET");
|
||||
let storage = GcsStorage::connect(bucket).await.unwrap();
|
||||
storage
|
||||
.put(
|
||||
"corex-storage-test.txt",
|
||||
bytes_stream(b"hello gcs".to_vec()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let data = read_to_vec(storage.get("corex-storage-test.txt").await.unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(data, b"hello gcs");
|
||||
storage.delete("corex-storage-test.txt").await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! # corex-storage
|
||||
//!
|
||||
//! A unified storage & file system abstraction so file handling doesn't get
|
||||
//! locked to one physical storage location.
|
||||
//!
|
||||
//! - **Local disk** ([`local::LocalFileStorage`], feature `local`, default).
|
||||
//! - **S3-compatible object storage** ([`s3::S3Storage`], feature `s3`) —
|
||||
//! works with Amazon S3 and any S3-compatible endpoint, including MinIO
|
||||
//! (pass a custom endpoint via [`s3::S3Storage::connect_with_endpoint`]).
|
||||
//! - **Google Cloud Storage** ([`gcs::GcsStorage`], feature `gcs`).
|
||||
//!
|
||||
//! All operations are stream-based (an [`traits::ObjectStream`] is a boxed
|
||||
//! [`tokio::io::AsyncRead`]), so uploading/downloading a huge object never
|
||||
//! requires holding it entirely in memory.
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```
|
||||
//! use corex_storage::{LocalFileStorage, StorageDriver, bytes_stream, read_to_vec};
|
||||
//! # #[tokio::main] async fn main() {
|
||||
//! # let dir = tempfile::tempdir().unwrap();
|
||||
//! let storage = LocalFileStorage::new(dir.path());
|
||||
//! storage.put("hello.txt", bytes_stream(b"hi".to_vec())).await.unwrap();
|
||||
//! let data = read_to_vec(storage.get("hello.txt").await.unwrap()).await.unwrap();
|
||||
//! assert_eq!(data, b"hi");
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
pub mod traits;
|
||||
|
||||
#[cfg(feature = "local")]
|
||||
pub mod local;
|
||||
|
||||
#[cfg(feature = "s3")]
|
||||
pub mod s3;
|
||||
|
||||
#[cfg(feature = "gcs")]
|
||||
pub mod gcs;
|
||||
|
||||
pub use traits::{
|
||||
bytes_stream, read_to_vec, ObjectMeta, ObjectStream, StorageDriver, StorageError,
|
||||
};
|
||||
|
||||
#[cfg(feature = "local")]
|
||||
pub use local::LocalFileStorage;
|
||||
|
||||
#[cfg(feature = "s3")]
|
||||
pub use s3::S3Storage;
|
||||
|
||||
#[cfg(feature = "gcs")]
|
||||
pub use gcs::GcsStorage;
|
||||
@@ -0,0 +1,165 @@
|
||||
//! A local-disk [`StorageDriver`] (feature `local`, default).
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::traits::{ObjectMeta, ObjectStream, StorageDriver, StorageError};
|
||||
|
||||
/// Stores objects as files under a root directory.
|
||||
///
|
||||
/// Paths are always resolved relative to the configured root; `..` path
|
||||
/// components are rejected to prevent escaping the root.
|
||||
#[derive(Clone)]
|
||||
pub struct LocalFileStorage {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl LocalFileStorage {
|
||||
/// Builds a driver rooted at `root`. The directory is not required to
|
||||
/// exist yet; it's created lazily on first write.
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
fn resolve(&self, path: &str) -> Result<PathBuf, StorageError> {
|
||||
let rel = Path::new(path.trim_start_matches('/'));
|
||||
for component in rel.components() {
|
||||
if matches!(component, Component::ParentDir | Component::Prefix(_)) {
|
||||
return Err(StorageError::InvalidPath(path.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(self.root.join(rel))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StorageDriver for LocalFileStorage {
|
||||
async fn get(&self, path: &str) -> Result<ObjectStream, StorageError> {
|
||||
let full = self.resolve(path)?;
|
||||
let file = tokio::fs::File::open(&full).await.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
StorageError::NotFound(path.to_string())
|
||||
} else {
|
||||
StorageError::Io(e.to_string())
|
||||
}
|
||||
})?;
|
||||
Ok(Box::pin(file))
|
||||
}
|
||||
|
||||
async fn put(&self, path: &str, mut data: ObjectStream) -> Result<u64, StorageError> {
|
||||
let full = self.resolve(path)?;
|
||||
if let Some(parent) = full.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|e| StorageError::Io(e.to_string()))?;
|
||||
}
|
||||
let mut file = tokio::fs::File::create(&full)
|
||||
.await
|
||||
.map_err(|e| StorageError::Io(e.to_string()))?;
|
||||
let written = tokio::io::copy(&mut data, &mut file)
|
||||
.await
|
||||
.map_err(|e| StorageError::Io(e.to_string()))?;
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<(), StorageError> {
|
||||
let full = self.resolve(path)?;
|
||||
match tokio::fs::remove_file(&full).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
Err(StorageError::NotFound(path.to_string()))
|
||||
}
|
||||
Err(e) => Err(StorageError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn exists(&self, path: &str) -> Result<bool, StorageError> {
|
||||
let full = self.resolve(path)?;
|
||||
Ok(tokio::fs::metadata(&full).await.is_ok())
|
||||
}
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<ObjectMeta, StorageError> {
|
||||
let full = self.resolve(path)?;
|
||||
let meta = tokio::fs::metadata(&full).await.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
StorageError::NotFound(path.to_string())
|
||||
} else {
|
||||
StorageError::Io(e.to_string())
|
||||
}
|
||||
})?;
|
||||
Ok(ObjectMeta {
|
||||
size: meta.len(),
|
||||
etag: None,
|
||||
last_modified: meta.modified().ok(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::{bytes_stream, read_to_vec};
|
||||
|
||||
fn driver() -> (LocalFileStorage, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
(LocalFileStorage::new(dir.path()), dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_get_roundtrip() {
|
||||
let (storage, _dir) = driver();
|
||||
let written = storage
|
||||
.put("a/b/file.txt", bytes_stream(b"hello world".to_vec()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(written, 11);
|
||||
let data = read_to_vec(storage.get("a/b/file.txt").await.unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(data, b"hello world");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_missing_returns_not_found() {
|
||||
let (storage, _dir) = driver();
|
||||
// `ObjectStream` isn't `Debug`, so match rather than `unwrap_err()`.
|
||||
match storage.get("missing.txt").await {
|
||||
Err(StorageError::NotFound(_)) => {}
|
||||
other => panic!("expected NotFound, got {:?}", other.is_ok()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_object() {
|
||||
let (storage, _dir) = driver();
|
||||
storage
|
||||
.put("x.txt", bytes_stream(b"x".to_vec()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(storage.exists("x.txt").await.unwrap());
|
||||
storage.delete("x.txt").await.unwrap();
|
||||
assert!(!storage.exists("x.txt").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stat_reports_size() {
|
||||
let (storage, _dir) = driver();
|
||||
storage
|
||||
.put("s.bin", bytes_stream(vec![0u8; 1024]))
|
||||
.await
|
||||
.unwrap();
|
||||
let meta = storage.stat("s.bin").await.unwrap();
|
||||
assert_eq!(meta.size, 1024);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn path_traversal_is_rejected() {
|
||||
let (storage, _dir) = driver();
|
||||
let err = storage
|
||||
.put("../escape.txt", bytes_stream(b"x".to_vec()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, StorageError::InvalidPath(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
//! An S3-compatible [`StorageDriver`] (feature `s3`), via `aws-sdk-s3`.
|
||||
//!
|
||||
//! Works against Amazon S3 or any S3-compatible endpoint — pass a custom
|
||||
//! endpoint via [`S3Storage::connect_with_endpoint`] to target MinIO or
|
||||
//! another compatible service.
|
||||
//!
|
||||
//! [`put`](S3Storage::put) streams the input in fixed-size chunks through
|
||||
//! S3's multipart upload API rather than buffering the whole object in
|
||||
//! memory, so large uploads stay within a bounded memory footprint.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use aws_sdk_s3::config::{Credentials, Region};
|
||||
use aws_sdk_s3::primitives::ByteStream as AwsByteStream;
|
||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
|
||||
use aws_sdk_s3::Client;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
use crate::traits::{ObjectMeta, ObjectStream, StorageDriver, StorageError};
|
||||
|
||||
/// S3's minimum multipart part size (5 MiB), except for the final part.
|
||||
const MULTIPART_CHUNK_SIZE: usize = 8 * 1024 * 1024;
|
||||
|
||||
fn map_sdk_err<E: std::fmt::Debug>(e: E) -> StorageError {
|
||||
StorageError::Io(format!("{e:?}"))
|
||||
}
|
||||
|
||||
/// A [`StorageDriver`] backed by an S3 (or S3-compatible) bucket.
|
||||
#[derive(Clone)]
|
||||
pub struct S3Storage {
|
||||
client: Client,
|
||||
bucket: String,
|
||||
}
|
||||
|
||||
impl S3Storage {
|
||||
/// Connects using the ambient AWS environment/credential chain
|
||||
/// (`AWS_ACCESS_KEY_ID`, IAM role, profile, etc.) against real S3.
|
||||
pub async fn connect(bucket: impl Into<String>) -> Self {
|
||||
let config = aws_config::load_from_env().await;
|
||||
let client = Client::new(&config);
|
||||
Self {
|
||||
client,
|
||||
bucket: bucket.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Connects to a custom S3-compatible endpoint (e.g. MinIO) with static
|
||||
/// credentials and path-style addressing.
|
||||
pub async fn connect_with_endpoint(
|
||||
bucket: impl Into<String>,
|
||||
endpoint: &str,
|
||||
region: &str,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Self {
|
||||
let credentials = Credentials::new(access_key, secret_key, None, None, "corex-storage");
|
||||
let config = aws_sdk_s3::Config::builder()
|
||||
.region(Region::new(region.to_string()))
|
||||
.endpoint_url(endpoint)
|
||||
.credentials_provider(credentials)
|
||||
.force_path_style(true)
|
||||
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
|
||||
.build();
|
||||
Self {
|
||||
client: Client::from_conf(config),
|
||||
bucket: bucket.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps an already-configured client (advanced use: sharing a client
|
||||
/// across multiple `S3Storage` instances pointed at different buckets).
|
||||
pub fn from_client(client: Client, bucket: impl Into<String>) -> Self {
|
||||
Self {
|
||||
client,
|
||||
bucket: bucket.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StorageDriver for S3Storage {
|
||||
async fn get(&self, path: &str) -> Result<ObjectStream, StorageError> {
|
||||
let resp = self
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(path)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if is_not_found(&e) {
|
||||
StorageError::NotFound(path.to_string())
|
||||
} else {
|
||||
map_sdk_err(e)
|
||||
}
|
||||
})?;
|
||||
Ok(Box::pin(resp.body.into_async_read()))
|
||||
}
|
||||
|
||||
async fn put(&self, path: &str, mut data: ObjectStream) -> Result<u64, StorageError> {
|
||||
// Read the first chunk to decide between a simple `PutObject` (small
|
||||
// objects) and a streamed multipart upload (anything larger).
|
||||
let mut first_chunk = vec![0u8; MULTIPART_CHUNK_SIZE];
|
||||
let mut filled = 0usize;
|
||||
while filled < first_chunk.len() {
|
||||
let n = data
|
||||
.read(&mut first_chunk[filled..])
|
||||
.await
|
||||
.map_err(|e| StorageError::Io(e.to_string()))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
filled += n;
|
||||
}
|
||||
first_chunk.truncate(filled);
|
||||
|
||||
if filled < MULTIPART_CHUNK_SIZE {
|
||||
// The whole object fit in one chunk: a single PutObject suffices.
|
||||
let len = first_chunk.len() as u64;
|
||||
self.client
|
||||
.put_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(path)
|
||||
.body(AwsByteStream::from(first_chunk))
|
||||
.send()
|
||||
.await
|
||||
.map_err(map_sdk_err)?;
|
||||
return Ok(len);
|
||||
}
|
||||
|
||||
// Larger objects: stream the rest through multipart upload.
|
||||
let create = self
|
||||
.client
|
||||
.create_multipart_upload()
|
||||
.bucket(&self.bucket)
|
||||
.key(path)
|
||||
.send()
|
||||
.await
|
||||
.map_err(map_sdk_err)?;
|
||||
let upload_id = create
|
||||
.upload_id()
|
||||
.ok_or_else(|| StorageError::Io("S3 did not return an upload id".to_string()))?;
|
||||
|
||||
let result = upload_parts(
|
||||
&self.client,
|
||||
&self.bucket,
|
||||
path,
|
||||
upload_id,
|
||||
first_chunk,
|
||||
&mut data,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok((total, parts)) => {
|
||||
self.client
|
||||
.complete_multipart_upload()
|
||||
.bucket(&self.bucket)
|
||||
.key(path)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.set_parts(Some(parts))
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(map_sdk_err)?;
|
||||
Ok(total)
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = self
|
||||
.client
|
||||
.abort_multipart_upload()
|
||||
.bucket(&self.bucket)
|
||||
.key(path)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<(), StorageError> {
|
||||
self.client
|
||||
.delete_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(path)
|
||||
.send()
|
||||
.await
|
||||
.map_err(map_sdk_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn exists(&self, path: &str) -> Result<bool, StorageError> {
|
||||
match self
|
||||
.client
|
||||
.head_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(path)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) if is_not_found(&e) => Ok(false),
|
||||
Err(e) => Err(map_sdk_err(e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<ObjectMeta, StorageError> {
|
||||
let resp = self
|
||||
.client
|
||||
.head_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(path)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if is_not_found(&e) {
|
||||
StorageError::NotFound(path.to_string())
|
||||
} else {
|
||||
map_sdk_err(e)
|
||||
}
|
||||
})?;
|
||||
Ok(ObjectMeta {
|
||||
size: resp.content_length().unwrap_or(0).max(0) as u64,
|
||||
etag: resp.e_tag().map(|s| s.to_string()),
|
||||
last_modified: resp
|
||||
.last_modified()
|
||||
.and_then(|d| d.to_owned().try_into().ok()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads `first_chunk` as part 1, then continues reading `data` in
|
||||
/// [`MULTIPART_CHUNK_SIZE`] chunks until exhausted, uploading each as a part.
|
||||
async fn upload_parts(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
upload_id: &str,
|
||||
first_chunk: Vec<u8>,
|
||||
data: &mut ObjectStream,
|
||||
) -> Result<(u64, Vec<CompletedPart>), StorageError> {
|
||||
let mut total = 0u64;
|
||||
let mut parts = Vec::new();
|
||||
let mut part_number = 1i32;
|
||||
let mut chunk = first_chunk;
|
||||
|
||||
loop {
|
||||
total += chunk.len() as u64;
|
||||
let resp = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(part_number)
|
||||
.body(AwsByteStream::from(chunk))
|
||||
.send()
|
||||
.await
|
||||
.map_err(map_sdk_err)?;
|
||||
parts.push(
|
||||
CompletedPart::builder()
|
||||
.e_tag(resp.e_tag().unwrap_or_default())
|
||||
.part_number(part_number)
|
||||
.build(),
|
||||
);
|
||||
part_number += 1;
|
||||
|
||||
let mut next = vec![0u8; MULTIPART_CHUNK_SIZE];
|
||||
let mut filled = 0usize;
|
||||
while filled < next.len() {
|
||||
let n = data
|
||||
.read(&mut next[filled..])
|
||||
.await
|
||||
.map_err(|e| StorageError::Io(e.to_string()))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
filled += n;
|
||||
}
|
||||
next.truncate(filled);
|
||||
if next.is_empty() {
|
||||
break;
|
||||
}
|
||||
chunk = next;
|
||||
}
|
||||
|
||||
Ok((total, parts))
|
||||
}
|
||||
|
||||
/// Best-effort detection of a "not found" S3 error across the SDK's error
|
||||
/// variants (`GetObject`/`HeadObject` surface this differently).
|
||||
fn is_not_found<E: std::fmt::Debug>(e: &E) -> bool {
|
||||
format!("{e:?}").contains("NotFound") || format!("{e:?}").contains("404")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::{bytes_stream, read_to_vec};
|
||||
|
||||
/// Requires a live S3-compatible endpoint (e.g. MinIO) configured via
|
||||
/// `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`. Run with:
|
||||
/// `S3_ENDPOINT=http://127.0.0.1:9000 S3_BUCKET=test S3_ACCESS_KEY=... S3_SECRET_KEY=... \
|
||||
/// cargo test -p corex-storage --features s3 -- --ignored`.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a live S3-compatible endpoint (S3_ENDPOINT, S3_BUCKET, ...)"]
|
||||
async fn put_get_roundtrip_live() {
|
||||
let endpoint = std::env::var("S3_ENDPOINT").expect("set S3_ENDPOINT");
|
||||
let bucket = std::env::var("S3_BUCKET").expect("set S3_BUCKET");
|
||||
let access_key = std::env::var("S3_ACCESS_KEY").expect("set S3_ACCESS_KEY");
|
||||
let secret_key = std::env::var("S3_SECRET_KEY").expect("set S3_SECRET_KEY");
|
||||
|
||||
let storage = S3Storage::connect_with_endpoint(
|
||||
bucket,
|
||||
&endpoint,
|
||||
"us-east-1",
|
||||
&access_key,
|
||||
&secret_key,
|
||||
)
|
||||
.await;
|
||||
|
||||
storage
|
||||
.put("corex-storage-test.txt", bytes_stream(b"hello s3".to_vec()))
|
||||
.await
|
||||
.unwrap();
|
||||
let data = read_to_vec(storage.get("corex-storage-test.txt").await.unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(data, b"hello s3");
|
||||
storage.delete("corex-storage-test.txt").await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! The core [`StorageDriver`] trait and supporting types.
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::io::{AsyncRead, ReadBuf};
|
||||
|
||||
/// Errors returned by storage operations.
|
||||
#[derive(Debug)]
|
||||
pub enum StorageError {
|
||||
/// The object does not exist.
|
||||
NotFound(String),
|
||||
/// The path was rejected (e.g. attempted directory traversal).
|
||||
InvalidPath(String),
|
||||
/// An I/O or backend transport error occurred.
|
||||
Io(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StorageError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NotFound(p) => write!(f, "object not found: {p}"),
|
||||
Self::InvalidPath(p) => write!(f, "invalid path: {p}"),
|
||||
Self::Io(s) => write!(f, "storage io error: {s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StorageError {}
|
||||
|
||||
/// A boxed, unpin, streaming byte source used for both `get` results and
|
||||
/// `put` input, so large objects never need to be buffered in memory whole.
|
||||
pub type ObjectStream = Pin<Box<dyn AsyncRead + Send + Unpin>>;
|
||||
|
||||
/// Metadata about a stored object.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ObjectMeta {
|
||||
/// Size in bytes.
|
||||
pub size: u64,
|
||||
/// A backend-provided content hash/version tag, if available.
|
||||
pub etag: Option<String>,
|
||||
/// Last-modified timestamp, if available.
|
||||
pub last_modified: Option<SystemTime>,
|
||||
}
|
||||
|
||||
/// A unified interface for storing, reading, and deleting files regardless of
|
||||
/// the physical backend (local disk, S3/MinIO, Google Cloud Storage, ...).
|
||||
///
|
||||
/// All operations are stream-based: [`StorageDriver::put`] takes an
|
||||
/// [`ObjectStream`] and [`StorageDriver::get`] returns one, so a caller
|
||||
/// forwarding a large upload/download never has to hold the whole object in
|
||||
/// memory.
|
||||
#[async_trait]
|
||||
pub trait StorageDriver: Send + Sync {
|
||||
/// Opens `path` for streaming read.
|
||||
async fn get(&self, path: &str) -> Result<ObjectStream, StorageError>;
|
||||
|
||||
/// Writes `data` to `path`, returning the number of bytes written.
|
||||
async fn put(&self, path: &str, data: ObjectStream) -> Result<u64, StorageError>;
|
||||
|
||||
/// Removes the object at `path`.
|
||||
async fn delete(&self, path: &str) -> Result<(), StorageError>;
|
||||
|
||||
/// Whether an object exists at `path`.
|
||||
async fn exists(&self, path: &str) -> Result<bool, StorageError>;
|
||||
|
||||
/// Fetches metadata about the object at `path` without downloading it.
|
||||
async fn stat(&self, path: &str) -> Result<ObjectMeta, StorageError>;
|
||||
}
|
||||
|
||||
/// A minimal, dependency-free in-memory [`AsyncRead`] over an owned buffer.
|
||||
struct VecCursor {
|
||||
data: Vec<u8>,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl AsyncRead for VecCursor {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<std::io::Result<()>> {
|
||||
let remaining = &self.data[self.pos..];
|
||||
let n = remaining.len().min(buf.remaining());
|
||||
buf.put_slice(&remaining[..n]);
|
||||
self.pos += n;
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: wraps an owned `Vec<u8>` as an [`ObjectStream`] for
|
||||
/// [`StorageDriver::put`].
|
||||
pub fn bytes_stream(data: Vec<u8>) -> ObjectStream {
|
||||
Box::pin(VecCursor { data, pos: 0 })
|
||||
}
|
||||
|
||||
/// Convenience: drains an [`ObjectStream`] into an owned `Vec<u8>` (defeats
|
||||
/// the point of streaming for huge objects — intended for tests/small files).
|
||||
pub async fn read_to_vec(mut stream: ObjectStream) -> Result<Vec<u8>, StorageError> {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut buf = Vec::new();
|
||||
stream
|
||||
.read_to_end(&mut buf)
|
||||
.await
|
||||
.map_err(|e| StorageError::Io(e.to_string()))?;
|
||||
Ok(buf)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# [1.1.0](https://github.com/asepharyana/mytheclipse/compare/v1.0.0...v1.1.0) (2026-08-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add panic tracking and isolation with PanicTracker ([8dec413](https://github.com/asepharyana/mytheclipse/commit/8dec4130057420320500d7a9265ccb3cbfd0e030))
|
||||
|
||||
# [1.0.0](https://github.com/asepharyana/mytheclipse/compare/v0.2.4...v1.0.0) (2026-08-28)
|
||||
|
||||
|
||||
* feat!: rename CorexError to MytheclipseError ([7cdf525](https://github.com/asepharyana/mytheclipse/commit/7cdf52544c611b05c6365595a8ae713672706b67))
|
||||
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
* CorexError is renamed to MytheclipseError.
|
||||
|
||||
## [0.2.4](https://github.com/asepharyana/corex/compare/v0.2.3...v0.2.4) (2026-08-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **release:** add actions: write permission for workflow dispatch ([5684938](https://github.com/asepharyana/corex/commit/5684938b4cd5a133a57ee835426035a804edcb38))
|
||||
|
||||
## [0.2.3](https://github.com/asepharyana/corex/compare/v0.2.2...v0.2.3) (2026-08-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **release:** hardcode repo in successCmd gh api ([d3c35ec](https://github.com/asepharyana/corex/commit/d3c35eccd0e3e1da21bcfed50ce2ba1f82d03cc5))
|
||||
|
||||
## [0.2.2](https://github.com/asepharyana/corex/compare/v0.2.1...v0.2.2) (2026-08-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **release:** dispatch publish via gh api in exec successCmd ([3c04aa4](https://github.com/asepharyana/corex/commit/3c04aa44b6110c8ce15278d7dc76828c44dfc8d9))
|
||||
|
||||
## [0.2.1](https://github.com/asepharyana/corex/compare/v0.2.0...v0.2.1) (2026-08-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **release:** dispatch publish via exec successCmd not step output ([6c8c98e](https://github.com/asepharyana/corex/commit/6c8c98e86cf27b6a998124e3de3b226a78c82f66))
|
||||
|
||||
# [0.2.0](https://github.com/asepharyana/corex/compare/v0.1.0...v0.2.0) (2026-08-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **release:** remove --locked from cargo check in release prepare ([90322b5](https://github.com/asepharyana/corex/commit/90322b5f970aaf48b6da0adaf89c2f516aec0e4c))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **release:** add semantic-release auto versioning ([12a1ab1](https://github.com/asepharyana/corex/commit/12a1ab10d04dec40f8b93edb51bb6104f6a75bcb))
|
||||
Generated
+479
@@ -0,0 +1,479 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mytheclipse"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"num_cpus",
|
||||
"rand",
|
||||
"rayon",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_cpus"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"nu-ansi-term",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
[package]
|
||||
name = "mytheclipse"
|
||||
version = "1.0.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/mytheclipse"
|
||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||
description = "Resource-aware abstractions for async I/O, heavy compute, background queue management, resiliency, traffic control, lifecycle, and observability."
|
||||
readme = "README.md"
|
||||
keywords = ["async", "concurrency", "rayon", "tokio", "resource-management", "resiliency", "retry", "circuit-breaker", "rate-limit", "observability", "cron", "shutdown"]
|
||||
categories = ["asynchronous", "concurrency", "rust-patterns"]
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.53", features = ["full"], optional = true }
|
||||
rayon = { version = "1.12", optional = true }
|
||||
rand = { version = "0.8", optional = true }
|
||||
num_cpus = "1.17"
|
||||
tracing = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53", features = ["full"] }
|
||||
tracing-subscriber = "0.3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
io = ["dep:tokio"]
|
||||
compute = ["dep:rayon"]
|
||||
bg = ["dep:tokio"]
|
||||
resiliency = ["dep:tokio", "dep:rand"]
|
||||
traffic = ["dep:tokio"]
|
||||
lifecycle = ["dep:tokio"]
|
||||
observability = ["dep:tokio"]
|
||||
full = ["io", "compute", "bg", "resiliency", "traffic", "lifecycle", "observability"]
|
||||
|
||||
[[example]]
|
||||
name = "main"
|
||||
path = "examples/main.rs"
|
||||
required-features = ["full"]
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
rustdoc-args = ["--cfg", "docsrs"]
|
||||
Loaded 50 of 76 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user