Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52d9e1e93e | ||
|
|
19941156b4 | ||
|
|
b2d3f32d83 | ||
|
|
4d851c5a58 | ||
|
|
db4f277336 | ||
|
|
6a4b2b8f54 | ||
|
|
d119821339 | ||
|
|
ca39dea5db | ||
|
|
8dec413005 |
+60
-18
@@ -28,10 +28,10 @@ jobs:
|
|||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
components: clippy
|
components: clippy
|
||||||
- name: Clippy (all features)
|
- name: Clippy (workspace, all features)
|
||||||
run: cargo clippy --all-features -- -D warnings
|
run: cargo clippy --workspace --all-features -- -D warnings
|
||||||
- name: Clippy (no default features)
|
- name: Clippy (workspace, no default features)
|
||||||
run: cargo clippy --no-default-features -- -D warnings
|
run: cargo clippy --workspace --no-default-features -- -D warnings
|
||||||
|
|
||||||
test-matrix:
|
test-matrix:
|
||||||
name: Test (${{ matrix.name }})
|
name: Test (${{ matrix.name }})
|
||||||
@@ -40,16 +40,53 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- name: default features
|
# Whole-workspace sanity passes.
|
||||||
flags: ""
|
- name: workspace default features
|
||||||
- name: all features
|
flags: "--workspace"
|
||||||
flags: "--all-features"
|
- name: workspace all features
|
||||||
- name: io only
|
flags: "--workspace --all-features"
|
||||||
flags: "--no-default-features --features io"
|
# mytheclipse: execution primitives + resiliency/traffic/lifecycle/observability
|
||||||
- name: compute only
|
- name: mytheclipse / io only
|
||||||
flags: "--no-default-features --features compute"
|
flags: "-p mytheclipse --no-default-features --features io"
|
||||||
- name: bg only
|
- name: mytheclipse / compute only
|
||||||
flags: "--no-default-features --features bg"
|
flags: "-p mytheclipse --no-default-features --features compute"
|
||||||
|
- name: mytheclipse / bg only
|
||||||
|
flags: "-p mytheclipse --no-default-features --features bg"
|
||||||
|
- name: mytheclipse / resiliency only
|
||||||
|
flags: "-p mytheclipse --no-default-features --features resiliency"
|
||||||
|
- name: mytheclipse / traffic only
|
||||||
|
flags: "-p mytheclipse --no-default-features --features traffic"
|
||||||
|
- name: mytheclipse / lifecycle only
|
||||||
|
flags: "-p mytheclipse --no-default-features --features lifecycle"
|
||||||
|
- name: mytheclipse / observability only
|
||||||
|
flags: "-p mytheclipse --no-default-features --features observability"
|
||||||
|
# mytheclipse-cache
|
||||||
|
- name: mytheclipse-cache / default
|
||||||
|
flags: "-p mytheclipse-cache"
|
||||||
|
- name: mytheclipse-cache / l1-moka
|
||||||
|
flags: "-p mytheclipse-cache --no-default-features --features l1-moka"
|
||||||
|
- name: mytheclipse-cache / l2-redis
|
||||||
|
flags: "-p mytheclipse-cache --no-default-features --features l1-memory,l2-redis"
|
||||||
|
# mytheclipse-storage
|
||||||
|
- name: mytheclipse-storage / default (local)
|
||||||
|
flags: "-p mytheclipse-storage"
|
||||||
|
- name: mytheclipse-storage / s3
|
||||||
|
flags: "-p mytheclipse-storage --no-default-features --features s3"
|
||||||
|
- name: mytheclipse-storage / gcs
|
||||||
|
flags: "-p mytheclipse-storage --no-default-features --features gcs"
|
||||||
|
# mytheclipse-event
|
||||||
|
- name: mytheclipse-event / default (mem)
|
||||||
|
flags: "-p mytheclipse-event"
|
||||||
|
- name: mytheclipse-event / amqp
|
||||||
|
flags: "-p mytheclipse-event --no-default-features --features amqp"
|
||||||
|
- name: mytheclipse-event / nats
|
||||||
|
flags: "-p mytheclipse-event --no-default-features --features nats"
|
||||||
|
# mytheclipse-config
|
||||||
|
- name: mytheclipse-config / default
|
||||||
|
flags: "-p mytheclipse-config"
|
||||||
|
# mytheclipse-crypto
|
||||||
|
- name: mytheclipse-crypto / default
|
||||||
|
flags: "-p mytheclipse-crypto"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -59,12 +96,12 @@ jobs:
|
|||||||
run: cargo test ${{ matrix.flags }}
|
run: cargo test ${{ matrix.flags }}
|
||||||
|
|
||||||
example:
|
example:
|
||||||
name: Run example
|
name: Run mytheclipse example
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- run: cargo run --example main --features full
|
- run: cargo run -p mytheclipse --example main --features full
|
||||||
|
|
||||||
docs:
|
docs:
|
||||||
name: Docs check
|
name: Docs check
|
||||||
@@ -72,13 +109,18 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- run: RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps
|
- run: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps
|
||||||
|
|
||||||
package:
|
package:
|
||||||
name: Cargo package dry-run
|
name: Cargo package dry-run
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
crate:
|
||||||
|
[mytheclipse, mytheclipse-cache, mytheclipse-storage, mytheclipse-event, mytheclipse-config, mytheclipse-crypto]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- name: Package
|
- name: Package
|
||||||
run: cargo package --no-verify
|
run: cargo package -p ${{ matrix.crate }} --no-verify
|
||||||
|
|||||||
@@ -25,7 +25,15 @@ jobs:
|
|||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Publish mytheclipse
|
# The workspace crates have no interdependencies, so publish order
|
||||||
run: cargo publish --allow-dirty --no-verify
|
# doesn't matter for crates.io dependency resolution. A brief sleep
|
||||||
|
# between publishes avoids hitting crates.io's rate limit.
|
||||||
|
- name: Publish workspace crates
|
||||||
|
run: |
|
||||||
|
for crate in mytheclipse mytheclipse-cache mytheclipse-storage mytheclipse-event mytheclipse-config mytheclipse-crypto; do
|
||||||
|
echo "Publishing $crate..."
|
||||||
|
cargo publish -p "$crate" --allow-dirty --no-verify
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
env:
|
env:
|
||||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
# Cargo build artifacts
|
# Cargo build artifacts (workspace root and any nested crate target dirs)
|
||||||
/target
|
/target
|
||||||
|
target/
|
||||||
|
|
||||||
# Editor / OS noise
|
# Editor / OS noise
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@
|
|||||||
[
|
[
|
||||||
"@semantic-release/exec",
|
"@semantic-release/exec",
|
||||||
{
|
{
|
||||||
"prepareCmd": "sed -i 's/^version = \\\"[^\\\"]*\\\"/version = \\\"${nextRelease.version}\\\"/' Cargo.toml && cargo check",
|
"prepareCmd": "for f in crates/*/Cargo.toml; do sed -i 's/^version = \\\"[^\\\"]*\\\"/version = \\\"${nextRelease.version}\\\"/' \"$f\"; done && cargo check --workspace",
|
||||||
"successCmd": "gh api -X POST repos/asepharyana/mytheclipse/actions/workflows/publish.yml/dispatches -f ref=main -f 'inputs[tag]=v${nextRelease.version}'"
|
"successCmd": "gh api -X POST repos/asepharyana/mytheclipse/actions/workflows/publish.yml/dispatches -f ref=main -f 'inputs[tag]=v${nextRelease.version}'"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"@semantic-release/git",
|
"@semantic-release/git",
|
||||||
{
|
{
|
||||||
"assets": [
|
"assets": [
|
||||||
"Cargo.toml",
|
"crates/*/Cargo.toml",
|
||||||
"Cargo.lock",
|
"Cargo.lock",
|
||||||
"CHANGELOG.md"
|
"CHANGELOG.md"
|
||||||
],
|
],
|
||||||
|
|||||||
+11
-40
@@ -1,49 +1,20 @@
|
|||||||
# [1.0.0](https://github.com/asepharyana/mytheclipse/compare/v0.2.4...v1.0.0) (2026-08-28)
|
## [1.3.1](https://github.com/asepharyana/mytheclipse/compare/v1.3.0...v1.3.1) (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
|
### Bug Fixes
|
||||||
|
|
||||||
* **release:** add actions: write permission for workflow dispatch ([5684938](https://github.com/asepharyana/corex/commit/5684938b4cd5a133a57ee835426035a804edcb38))
|
* **ci:** gate event crate doctest behind mem feature and apply rustfmt ([1994115](https://github.com/asepharyana/mytheclipse/commit/19941156b43ec58370d0d1369174ec84400e9bc9))
|
||||||
|
|
||||||
## [0.2.3](https://github.com/asepharyana/corex/compare/v0.2.2...v0.2.3) (2026-08-28)
|
# [1.3.0](https://github.com/asepharyana/mytheclipse/compare/v1.2.0...v1.3.0) (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
|
### Features
|
||||||
|
|
||||||
* **release:** add semantic-release auto versioning ([12a1ab1](https://github.com/asepharyana/corex/commit/12a1ab10d04dec40f8b93edb51bb6104f6a75bcb))
|
* add corex-storage crate for unified storage abstraction ([db4f277](https://github.com/asepharyana/mytheclipse/commit/db4f277336d1e32cb6a2ddd86ac37ae9789fa4f8))
|
||||||
|
|
||||||
|
# [1.2.0](https://github.com/asepharyana/mytheclipse/compare/v1.1.0...v1.2.0) (2026-08-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add panic tracking and logging with PanicTracker ([d119821](https://github.com/asepharyana/mytheclipse/commit/d1198213391710ccc6f2c108b15aa4526380423d))
|
||||||
|
|||||||
Generated
+4545
-18
File diff suppressed because it is too large
Load Diff
+10
-40
@@ -1,40 +1,10 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "mytheclipse"
|
members = [
|
||||||
version = "1.0.0"
|
"crates/mytheclipse",
|
||||||
edition = "2021"
|
"crates/mytheclipse-cache",
|
||||||
rust-version = "1.75"
|
"crates/mytheclipse-storage",
|
||||||
license = "MIT OR Apache-2.0"
|
"crates/mytheclipse-event",
|
||||||
repository = "https://github.com/asepharyana/mytheclipse"
|
"crates/mytheclipse-config",
|
||||||
homepage = "https://github.com/asepharyana/mytheclipse"
|
"crates/mytheclipse-crypto",
|
||||||
documentation = "https://docs.rs/mytheclipse"
|
]
|
||||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
resolver = "2"
|
||||||
description = "Resource-aware abstractions for async I/O, heavy compute, and background queue management."
|
|
||||||
readme = "README.md"
|
|
||||||
keywords = ["async", "concurrency", "rayon", "tokio", "resource-management"]
|
|
||||||
categories = ["asynchronous", "concurrency", "rust-patterns"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
tokio = { version = "1.53", features = ["full"], optional = true }
|
|
||||||
rayon = { version = "1.12", 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"]
|
|
||||||
full = ["io", "compute", "bg"]
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "main"
|
|
||||||
path = "examples/main.rs"
|
|
||||||
required-features = ["full"]
|
|
||||||
|
|
||||||
[package.metadata.docs.rs]
|
|
||||||
all-features = true
|
|
||||||
rustdoc-args = ["--cfg", "docsrs"]
|
|
||||||
@@ -1,81 +1,64 @@
|
|||||||
# mytheclipse
|
# mytheclipse
|
||||||
|
|
||||||
[](https://crates.io/crates/mytheclipse)
|
A personal collection of Rust abstractions for building resource-aware,
|
||||||
[](https://docs.rs/mytheclipse)
|
resilient, and well-instrumented applications without hand-rolling the same
|
||||||
[](LICENSE-MIT)
|
plumbing every time — organized as a Cargo workspace, one focused crate per
|
||||||
|
concern.
|
||||||
|
|
||||||
Resource-aware execution primitives for Rust: async I/O, heavy compute, and background queue management, sized automatically from the host's logical core count and exposed through a single, lazily-initialized engine context.
|
[](crates/mytheclipse/LICENSE-MIT)
|
||||||
|
|
||||||
## Resource Sizing
|
## Crates
|
||||||
|
|
||||||
Given $N$ logical cores (via `num_cpus::get()`):
|
| Crate | Description | Docs |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| [`mytheclipse`](crates/mytheclipse) | Resource-aware execution primitives (async I/O, compute, background queues), resiliency (retry, circuit breaker, timeout), traffic control (rate limiter, backpressure, concurrency limiter), lifecycle (graceful shutdown, cron), and observability (metrics, panic tracking). | [README](crates/mytheclipse/README.md) |
|
||||||
|
| [`mytheclipse-cache`](crates/mytheclipse-cache) | Unified multi-layer (L1/L2) cache abstraction: in-memory or Moka L1, Redis/Valkey L2, cache-aside read-through. | [README](crates/mytheclipse-cache/README.md) |
|
||||||
|
| [`mytheclipse-storage`](crates/mytheclipse-storage) | Unified storage & file system abstraction: one driver interface over local disk, S3/MinIO, and Google Cloud Storage, stream-based. | [README](crates/mytheclipse-storage/README.md) |
|
||||||
|
| [`mytheclipse-event`](crates/mytheclipse-event) | Unified events & message bus abstraction: in-memory pub/sub dispatcher plus RabbitMQ and NATS broker adapters behind one trait. | [README](crates/mytheclipse-event/README.md) |
|
||||||
|
| [`mytheclipse-config`](crates/mytheclipse-config) | Type-safe, dynamic configuration engine: load `.env`/YAML/JSON/TOML into typed structs, with hot-reload. | [README](crates/mytheclipse-config/README.md) |
|
||||||
|
| [`mytheclipse-crypto`](crates/mytheclipse-crypto) | Safe hashing (Argon2id), encryption (AES-256-GCM), and JWT tokens, with key rotation support. | [README](crates/mytheclipse-crypto/README.md) |
|
||||||
|
|
||||||
| Subsystem | Sizing Formula | Default on 8 cores | Backing Primitive |
|
Every crate follows the same philosophy: **one small interface, pluggable
|
||||||
| :--- | :--- | :--- | :--- |
|
backends behind feature flags, and a working default that needs no external
|
||||||
| **Async I/O** | $N$ | 8 | Ambient `tokio::spawn` + `tracing` span |
|
service to build or test.** Distributed backends (Redis, S3, GCS, RabbitMQ,
|
||||||
| **Compute** | $\max(1, N - 1)$ | 7 | Sized `rayon::ThreadPool` + `catch_unwind` |
|
NATS) are feature-gated and their integration tests are `#[ignore]`d unless
|
||||||
| **Background Queue** | $\max(2, \lfloor N / 2 \rfloor)$ | 4 | `tokio::sync::Semaphore` + `tokio::spawn` |
|
the corresponding environment variables point at a live service.
|
||||||
|
|
||||||
## Features
|
## Getting started
|
||||||
|
|
||||||
- **`io`**: enables `mytheclipse::spawn_io`, instrumented async task spawning.
|
Each crate is published independently; add the ones you need:
|
||||||
- **`compute`**: enables `mytheclipse::compute`, panic-isolated execution on a sized Rayon pool.
|
|
||||||
- **`bg`**: enables `mytheclipse::spawn_bg`, semaphore-bounded background tasks.
|
|
||||||
- **`full`**: enables all three subsystems.
|
|
||||||
|
|
||||||
Zero features enabled by default (`default = []`), so you only pull in the dependencies your application actually uses.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
Add to your `Cargo.toml`:
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mytheclipse = { version = "0.1", features = ["full"] }
|
mytheclipse = { version = "1", features = ["full"] }
|
||||||
|
mytheclipse-cache = "0.1"
|
||||||
|
mytheclipse-storage = { version = "0.1", features = ["s3"] }
|
||||||
|
mytheclipse-event = { version = "0.1", features = ["nats"] }
|
||||||
|
mytheclipse-config = "0.1"
|
||||||
|
mytheclipse-crypto = "0.1"
|
||||||
```
|
```
|
||||||
|
|
||||||
Use the entry points directly:
|
See each crate's own README (linked above) for usage examples and the full
|
||||||
|
feature-flag list.
|
||||||
|
|
||||||
```rust
|
## Development
|
||||||
#[tokio::main]
|
|
||||||
async fn main() {
|
|
||||||
// Optional explicit bootstrap: logs or validates resource sizing upfront.
|
|
||||||
// Omit it and the first call to any primitive below will initialize it lazily.
|
|
||||||
let ctx = mytheclipse::init();
|
|
||||||
println!(
|
|
||||||
"io_threads={} compute_threads={} bg_concurrency={}",
|
|
||||||
ctx.io_threads, ctx.compute_threads, ctx.bg_concurrency
|
|
||||||
);
|
|
||||||
|
|
||||||
// 1. Async I/O (instrumented with tracing)
|
This is a Cargo workspace; run commands from the repository root:
|
||||||
let io = mytheclipse::spawn_io(async {
|
|
||||||
// ... network / disk work ...
|
|
||||||
42
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Heavy Compute (isolated from worker panics)
|
|
||||||
let sum = mytheclipse::compute(|| (1..=1_000_000u64).sum::<u64>())?;
|
|
||||||
|
|
||||||
// 3. Background Queue (concurrency-bounded)
|
|
||||||
let bg = mytheclipse::spawn_bg(async {
|
|
||||||
// ... deferred cleanup / telemetry ...
|
|
||||||
}).await;
|
|
||||||
|
|
||||||
let _ = (io.await, bg.await);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running the Example
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run --example main --features full
|
cargo build --workspace --all-features
|
||||||
|
cargo test --workspace --all-features
|
||||||
|
cargo clippy --workspace --all-features -- -D warnings
|
||||||
|
cargo fmt --all --check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or target a single crate with `-p <name>`, e.g. `cargo test -p mytheclipse-cache`.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Licensed under either of:
|
Licensed under either of:
|
||||||
|
|
||||||
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or <http://www.apache.org/licenses/LICENSE-2.0>)
|
- Apache License, Version 2.0 ([LICENSE-APACHE](crates/mytheclipse/LICENSE-APACHE) or <http://www.apache.org/licenses/LICENSE-2.0>)
|
||||||
- MIT license ([LICENSE-MIT](LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
|
- MIT license ([LICENSE-MIT](crates/mytheclipse/LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
|
||||||
|
|
||||||
at your option.
|
at your option.
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
[package]
|
||||||
|
name = "mytheclipse-cache"
|
||||||
|
version = "1.3.1"
|
||||||
|
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-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,33 @@
|
|||||||
|
# mytheclipse-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 mytheclipse_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 @@
|
|||||||
|
//! # mytheclipse-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 mytheclipse_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 mytheclipse-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, "mytheclipse_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 = "mytheclipse-config"
|
||||||
|
version = "1.3.1"
|
||||||
|
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-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 @@
|
|||||||
|
# mytheclipse-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 mytheclipse_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 mytheclipse_config::DynamicConfig;
|
||||||
|
# use serde::Deserialize;
|
||||||
|
# #[derive(Debug, Deserialize, Clone)] struct AppConfig { port: u16 }
|
||||||
|
|
||||||
|
let cfg = DynamicConfig::<AppConfig>::watch_files(
|
||||||
|
vec!["config.yaml".into()],
|
||||||
|
|| mytheclipse_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,194 @@
|
|||||||
|
//! 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("mytheclipse-config: RwLock poisoned")
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the current value and notifies subscribers.
|
||||||
|
pub fn set(&self, new: T) {
|
||||||
|
*self
|
||||||
|
.inner
|
||||||
|
.write()
|
||||||
|
.expect("mytheclipse-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("mytheclipse-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("mytheclipse-config: RwLock poisoned") = new;
|
||||||
|
let _ = tx.send(());
|
||||||
|
last_applied = Instant::now();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("mytheclipse-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 mytheclipse-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 @@
|
|||||||
|
//! # mytheclipse-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 mytheclipse_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 = "mytheclipse-crypto"
|
||||||
|
version = "1.3.1"
|
||||||
|
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-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 @@
|
|||||||
|
# mytheclipse-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 mytheclipse_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 @@
|
|||||||
|
//! # mytheclipse-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 mytheclipse_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 mytheclipse-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 = "mytheclipse-event"
|
||||||
|
version = "1.3.1"
|
||||||
|
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-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 @@
|
|||||||
|
# mytheclipse-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 mytheclipse_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 `mytheclipse.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(),
|
||||||
|
"mytheclipse-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 mytheclipse-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, "mytheclipse_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,67 @@
|
|||||||
|
//! # mytheclipse-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
|
||||||
|
//!
|
||||||
|
//! The in-memory + typed bus (`mem` feature, on by default):
|
||||||
|
//!
|
||||||
|
//! ```no_run
|
||||||
|
//! use serde::{Deserialize, Serialize};
|
||||||
|
//!
|
||||||
|
//! #[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
//! struct OrderCreated { id: u64 }
|
||||||
|
//!
|
||||||
|
//! #[cfg(feature = "mem")]
|
||||||
|
//! #[tokio::main]
|
||||||
|
//! async fn main() {
|
||||||
|
//! use mytheclipse_event::{EventBus, InMemoryEventBus, TypedEventBus};
|
||||||
|
//! 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 });
|
||||||
|
//! }
|
||||||
|
//!
|
||||||
|
//! #[cfg(not(feature = "mem"))]
|
||||||
|
//! fn main() {}
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
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 mytheclipse-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 = "mytheclipse-storage"
|
||||||
|
version = "1.3.1"
|
||||||
|
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-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 @@
|
|||||||
|
# mytheclipse-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 mytheclipse_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.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user