Compare commits

..
16 Commits
Author SHA1 Message Date
semantic-release-bot 52d9e1e93e chore(release): 1.3.1 [skip ci]
## [1.3.1](https://github.com/asepharyana/mytheclipse/compare/v1.3.0...v1.3.1) (2026-08-28)

### Bug Fixes

* **ci:** gate event crate doctest behind mem feature and apply rustfmt ([1994115](https://github.com/asepharyana/mytheclipse/commit/19941156b43ec58370d0d1369174ec84400e9bc9))
2026-08-28 15:41:53 +00:00
asepharyana 19941156b4 fix(ci): gate event crate doctest behind mem feature and apply rustfmt
The crate-root doctest used InMemoryEventBus/TypedEventBus (gated behind
the 'mem' feature), so `cargo test -p mytheclipse-event --no-default-features
--features amqp|nats` failed to compile the doctest (E0432). The example is
now no_run with the mem-dependent imports inside a #[cfg(feature="mem")]
main, so it compiles (and runs) when mem is on and degrades to an empty main
when off.

Also apply rustfmt to mytheclipse-config/dynamic.rs and
mytheclipse-storage/{gcs,s3}.rs to satisfy cargo fmt --all --check.
2026-08-28 22:41:06 +07:00
semantic-release-bot b2d3f32d83 chore(release): 1.3.0 [skip ci]
# [1.3.0](https://github.com/asepharyana/mytheclipse/compare/v1.2.0...v1.3.0) (2026-08-28)

### Features

* add corex-storage crate for unified storage abstraction ([db4f277](https://github.com/asepharyana/mytheclipse/commit/db4f277336d1e32cb6a2ddd86ac37ae9789fa4f8))
2026-08-28 15:30:52 +00:00
asepharyana 4d851c5a58 chore: rename corex-* crates to mytheclipse-* namespace
CI / Rustfmt (push) Canceled after 0s
CI / Clippy (push) Canceled after 0s
CI / Test (workspace all features) (push) Canceled after 0s
CI / Test (workspace default features) (push) Canceled after 0s
CI / Test (mytheclipse / bg only) (push) Canceled after 0s
CI / Test (mytheclipse / compute only) (push) Canceled after 0s
CI / Test (mytheclipse / io only) (push) Canceled after 0s
CI / Test (mytheclipse / lifecycle only) (push) Canceled after 0s
CI / Test (mytheclipse / observability only) (push) Canceled after 0s
CI / Test (mytheclipse / resiliency only) (push) Canceled after 0s
CI / Test (mytheclipse / traffic only) (push) Canceled after 0s
CI / Test (mytheclipse-cache / l2-redis) (push) Canceled after 0s
CI / Test (mytheclipse-cache / l1-moka) (push) Canceled after 0s
CI / Test (mytheclipse-cache / default) (push) Canceled after 0s
CI / Test (mytheclipse-config / default) (push) Canceled after 0s
CI / Test (mytheclipse-crypto / default) (push) Canceled after 0s
CI / Test (mytheclipse-event / amqp) (push) Canceled after 0s
CI / Test (mytheclipse-event / nats) (push) Canceled after 0s
CI / Test (mytheclipse-event / default (mem)) (push) Canceled after 0s
CI / Test (mytheclipse-storage / gcs) (push) Canceled after 0s
CI / Test (mytheclipse-storage / s3) (push) Canceled after 0s
CI / Test (mytheclipse-storage / default (local)) (push) Canceled after 0s
CI / Run mytheclipse example (push) Canceled after 0s
CI / Docs check (push) Canceled after 0s
CI / Cargo package dry-run (mytheclipse) (push) Canceled after 0s
CI / Cargo package dry-run (mytheclipse-cache) (push) Canceled after 0s
CI / Cargo package dry-run (mytheclipse-config) (push) Canceled after 0s
CI / Cargo package dry-run (mytheclipse-crypto) (push) Canceled after 0s
CI / Cargo package dry-run (mytheclipse-event) (push) Canceled after 0s
CI / Cargo package dry-run (mytheclipse-storage) (push) Canceled after 0s
Release / Semantic Release (push) Canceled after 0s
Rename all `corex-cache`, `corex-storage`, `corex-event`, `corex-config`,
and `corex-crypto` crates to `mytheclipse-cache`, `mytheclipse-storage`,
`mytheclipse-event`, `mytheclipse-config`, and `mytheclipse-crypto`
respectively, aligning with the `mytheclipse` root crate naming convention.

- Convert root `Cargo.toml` from a package manifest to a workspace
  manifest with explicit member paths
- Update crate names in each `Cargo.toml` and docs.rs URLs
- Update all import paths, module doc comments, and error/panic messages
  from `corex-*` to `mytheclipse-*` across source and README files
- Update CI workflow and publish script to reference the new crate names<tool_call></think>chore: rename corex-* crates to mytheclipse-* namespace

Rename all `corex-cache`, `corex-storage`, `corex-event`, `corex-config`,
and `corex-crypto` crates to `mytheclipse-cache`, `mytheclipse-storage`,
`mytheclipse-event`, `mytheclipse-config`, and `mytheclipse-crypto`
respectively, aligning with the `mytheclipse` root crate naming convention.

- Convert root `Cargo.toml` from a package manifest to a workspace
  manifest with explicit member paths
- Update crate names in each `Cargo.toml` and docs.rs URLs
- Update all import paths, module doc comments, and error/panic messages
  from `corex-*` to `mytheclipse-*` across source and README files
- Update CI workflow and publish script to reference the new crate names
2026-08-28 22:29:49 +07:00
asepharyana db4f277336 feat: add corex-storage crate for unified storage abstraction
- Introduced corex-storage crate with support for local disk, S3-compatible, and Google Cloud Storage backends.
- Implemented StorageDriver trait for various storage backends.
- Added LocalFileStorage for local disk operations.
- Added S3Storage for S3-compatible object storage with multipart upload support.
- Added GcsStorage for Google Cloud Storage operations.
- Included error handling for storage operations.
- Added tests for each storage backend to ensure functionality.
- Created README.md for documentation and usage examples.
- Added Apache and MIT licenses for open-source compliance.
2026-08-28 22:24:18 +07:00
semantic-release-bot 6a4b2b8f54 chore(release): 1.2.0 [skip ci]
# [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))
2026-08-28 14:46:51 +00:00
asepharyana d119821339 feat: add panic tracking and logging with PanicTracker
- Implemented PanicTracker to log panics using tracing within a dedicated span.
- Introduced PanicInfo struct to capture panic details.
- Added tests for panic catching and hook restoration.

feat: implement token-bucket rate limiter

- Created RateLimiter to manage token consumption with configurable rates and burst capacity.
- Added methods for acquiring tokens and checking available tokens.
- Included tests for rate limiting behavior and token availability.

feat: add automatic retry with exponential backoff

- Developed retry function to handle transient errors with configurable retry strategies.
- Introduced RetryConfig for customizing retry behavior and jitter.
- Added tests for various retry scenarios and error handling.

feat: implement graceful shutdown coordination

- Created ShutdownManager to manage shutdown signals and task completion.
- Introduced ShutdownSignal for tasks to observe shutdown requests.
- Added tests for shutdown request handling and task draining.

feat: add timeout functionality for async operations

- Implemented with_timeout function to enforce execution time limits on futures.
- Created Timeout struct for wrapping futures with a deadline.
- Added tests for timeout behavior and error mapping.
2026-08-28 21:46:18 +07:00
semantic-release-bot ca39dea5db chore(release): 1.1.0 [skip ci]
# [1.1.0](https://github.com/asepharyana/mytheclipse/compare/v1.0.0...v1.1.0) (2026-08-28)

### Features

* add panic tracking and isolation with PanicTracker ([8dec413](https://github.com/asepharyana/mytheclipse/commit/8dec4130057420320500d7a9265ccb3cbfd0e030))
2026-08-28 11:45:57 +00:00
asepharyana 8dec413005 feat: add panic tracking and isolation with PanicTracker
- Implemented PanicTracker for logging panics using tracing.
- Introduced PanicInfo struct to capture panic details.
- Added tests for panic catching and hook restoration.

feat: implement token-bucket rate limiter

- Created RateLimiter for controlling token consumption with burst capacity.
- Added methods for acquiring tokens and checking available tokens.
- Included tests for rate limiting behavior and error handling.

feat: add automatic retry with exponential backoff

- Developed retry function to handle transient errors with configurable retry logic.
- Introduced RetryConfig for customizing retry parameters.
- Added tests for various retry scenarios and error handling.

feat: implement graceful shutdown coordination

- Created ShutdownManager to handle OS signals and manage task shutdown.
- Introduced ShutdownSignal for tasks to observe shutdown requests.
- Added tests for shutdown behavior and task management.

feat: add timeout functionality for async operations

- Implemented with_timeout function to enforce execution time limits.
- Created Timeout struct for wrapping futures with deadlines.
- Added tests for timeout behavior and error handling.
2026-08-28 18:45:20 +07:00
semantic-release-bot 4043a681db chore(release): 1.0.0 [skip ci]
# [1.0.0](https://github.com/asepharyana/mytheclipse/compare/v0.2.4...v1.0.0) (2026-08-28)

* feat!: rename CorexError to MytheclipseError ([7cdf525](https://github.com/asepharyana/mytheclipse/commit/7cdf52544c611b05c6365595a8ae713672706b67))

### BREAKING CHANGES

* CorexError is renamed to MytheclipseError.
2026-08-28 10:27:09 +00:00
asepharyana 7cdf52544c feat!: rename CorexError to MytheclipseError
Complete the mytheclipse rebranding: the public error type now uses the
mytheclipse name. Breaking API change (CorexError -> MytheclipseError),
hence the major version bump.

BREAKING CHANGE: CorexError is renamed to MytheclipseError.
2026-08-28 17:26:18 +07:00
asepharyana fa14529aa2 chore(repo): rename references to mytheclipse after GitHub repo rename
Update repository/homepage URLs in Cargo.toml and the gh api dispatch
repo path in .releaserc.json from asepharyana/corex to
asepharyana/mytheclipse (repo renamed on GitHub).
2026-08-28 17:22:23 +07:00
semantic-release-bot 4b6e9f35ef chore(release): 0.2.4 [skip ci]
## [0.2.4](https://github.com/asepharyana/corex/compare/v0.2.3...v0.2.4) (2026-08-28)

### Bug Fixes

* **release:** add actions: write permission for workflow dispatch ([5684938](https://github.com/asepharyana/corex/commit/5684938b4cd5a133a57ee835426035a804edcb38))
2026-08-28 10:19:23 +00:00
asepharyana 5684938b4c fix(release): add actions: write permission for workflow dispatch
gh api dispatch to publish.yml returned HTTP 403 because GITHUB_TOKEN
lacked actions: write. Add it to the release workflow permissions.
2026-08-28 17:18:45 +07:00
semantic-release-bot c830d2b949 chore(release): 0.2.3 [skip ci]
## [0.2.3](https://github.com/asepharyana/corex/compare/v0.2.2...v0.2.3) (2026-08-28)

### Bug Fixes

* **release:** hardcode repo in successCmd gh api ([d3c35ec](https://github.com/asepharyana/corex/commit/d3c35eccd0e3e1da21bcfed50ce2ba1f82d03cc5))
2026-08-28 10:17:43 +00:00
asepharyana d3c35eccd0 fix(release): hardcode repo in successCmd gh api
GITHUB_REPOSITORY is not in semantic-release template context, causing
ReferenceError in successCmd. Use the static repo path asepharyana/corex.
2026-08-28 17:17:17 +07:00
82 changed files with 13571 additions and 291 deletions
+60 -18
View File
@@ -28,10 +28,10 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Clippy (all features)
run: cargo clippy --all-features -- -D warnings
- name: Clippy (no default features)
run: cargo clippy --no-default-features -- -D warnings
- name: Clippy (workspace, all features)
run: cargo clippy --workspace --all-features -- -D warnings
- name: Clippy (workspace, no default features)
run: cargo clippy --workspace --no-default-features -- -D warnings
test-matrix:
name: Test (${{ matrix.name }})
@@ -40,16 +40,53 @@ jobs:
fail-fast: false
matrix:
include:
- name: default features
flags: ""
- name: all features
flags: "--all-features"
- name: io only
flags: "--no-default-features --features io"
- name: compute only
flags: "--no-default-features --features compute"
- name: bg only
flags: "--no-default-features --features bg"
# Whole-workspace sanity passes.
- name: workspace default features
flags: "--workspace"
- name: workspace all features
flags: "--workspace --all-features"
# mytheclipse: execution primitives + resiliency/traffic/lifecycle/observability
- name: mytheclipse / io only
flags: "-p mytheclipse --no-default-features --features io"
- name: mytheclipse / compute only
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:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
@@ -59,12 +96,12 @@ jobs:
run: cargo test ${{ matrix.flags }}
example:
name: Run example
name: Run mytheclipse example
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo run --example main --features full
- run: cargo run -p mytheclipse --example main --features full
docs:
name: Docs check
@@ -72,13 +109,18 @@ jobs:
steps:
- uses: actions/checkout@v4
- 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:
name: Cargo package dry-run
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
crate:
[mytheclipse, mytheclipse-cache, mytheclipse-storage, mytheclipse-event, mytheclipse-config, mytheclipse-crypto]
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Package
run: cargo package --no-verify
run: cargo package -p ${{ matrix.crate }} --no-verify
+10 -2
View File
@@ -25,7 +25,15 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- name: Publish mytheclipse
run: cargo publish --allow-dirty --no-verify
# The workspace crates have no interdependencies, so publish order
# 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:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+1
View File
@@ -9,6 +9,7 @@ permissions:
contents: write
issues: write
pull-requests: write
actions: write
jobs:
release:
+2 -1
View File
@@ -1,5 +1,6 @@
# Cargo build artifacts
# Cargo build artifacts (workspace root and any nested crate target dirs)
/target
target/
# Editor / OS noise
.DS_Store
+27 -12
View File
@@ -1,19 +1,34 @@
{
"branches": ["main"],
"branches": [
"main"
],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
["@semantic-release/exec", {
"prepareCmd": "sed -i 's/^version = \\\"[^\\\"]*\\\"/version = \\\"${nextRelease.version}\\\"/' Cargo.toml && cargo check",
"successCmd": "gh api -X POST repos/${GITHUB_REPOSITORY}/actions/workflows/publish.yml/dispatches -f ref=main -f 'inputs[tag]=v${nextRelease.version}'"
}],
["@semantic-release/git", {
"assets": ["Cargo.toml", "Cargo.lock", "CHANGELOG.md"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}],
["@semantic-release/github", {
"assets": []
}]
[
"@semantic-release/exec",
{
"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}'"
}
],
[
"@semantic-release/git",
{
"assets": [
"crates/*/Cargo.toml",
"Cargo.lock",
"CHANGELOG.md"
],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
],
[
"@semantic-release/github",
{
"assets": []
}
]
]
}
+11 -16
View File
@@ -1,25 +1,20 @@
## [0.2.2](https://github.com/asepharyana/corex/compare/v0.2.1...v0.2.2) (2026-08-28)
## [1.3.1](https://github.com/asepharyana/mytheclipse/compare/v1.3.0...v1.3.1) (2026-08-28)
### Bug Fixes
* **release:** dispatch publish via gh api in exec successCmd ([3c04aa4](https://github.com/asepharyana/corex/commit/3c04aa44b6110c8ce15278d7dc76828c44dfc8d9))
* **ci:** gate event crate doctest behind mem feature and apply rustfmt ([1994115](https://github.com/asepharyana/mytheclipse/commit/19941156b43ec58370d0d1369174ec84400e9bc9))
## [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))
# [1.3.0](https://github.com/asepharyana/mytheclipse/compare/v1.2.0...v1.3.0) (2026-08-28)
### 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
View File
File diff suppressed because it is too large Load Diff
+10 -40
View File
@@ -1,40 +1,10 @@
[package]
name = "mytheclipse"
version = "0.2.2"
edition = "2021"
rust-version = "1.75"
license = "MIT OR Apache-2.0"
repository = "https://github.com/asepharyana/corex"
homepage = "https://github.com/asepharyana/corex"
documentation = "https://docs.rs/mytheclipse"
authors = ["asepharyana <superaseph@gmail.com>"]
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"]
[workspace]
members = [
"crates/mytheclipse",
"crates/mytheclipse-cache",
"crates/mytheclipse-storage",
"crates/mytheclipse-event",
"crates/mytheclipse-config",
"crates/mytheclipse-crypto",
]
resolver = "2"
+40 -57
View File
@@ -1,81 +1,64 @@
# mytheclipse
[![Crates.io](https://img.shields.io/crates/v/mytheclipse.svg)](https://crates.io/crates/mytheclipse)
[![Documentation](https://docs.rs/mytheclipse/badge.svg)](https://docs.rs/mytheclipse)
[![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](LICENSE-MIT)
A personal collection of Rust abstractions for building resource-aware,
resilient, and well-instrumented applications without hand-rolling the same
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.
[![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](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 |
| :--- | :--- | :--- | :--- |
| **Async I/O** | $N$ | 8 | Ambient `tokio::spawn` + `tracing` span |
| **Compute** | $\max(1, N - 1)$ | 7 | Sized `rayon::ThreadPool` + `catch_unwind` |
| **Background Queue** | $\max(2, \lfloor N / 2 \rfloor)$ | 4 | `tokio::sync::Semaphore` + `tokio::spawn` |
Every crate follows the same philosophy: **one small interface, pluggable
backends behind feature flags, and a working default that needs no external
service to build or test.** Distributed backends (Redis, S3, GCS, RabbitMQ,
NATS) are feature-gated and their integration tests are `#[ignore]`d unless
the corresponding environment variables point at a live service.
## Features
## Getting started
- **`io`**: enables `mytheclipse::spawn_io`, instrumented async task spawning.
- **`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`:
Each crate is published independently; add the ones you need:
```toml
[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
#[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
);
## Development
// 1. Async I/O (instrumented with tracing)
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
This is a Cargo workspace; run commands from the repository root:
```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
Licensed under either of:
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or <http://www.apache.org/licenses/LICENSE-2.0>)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
- Apache License, Version 2.0 ([LICENSE-APACHE](crates/mytheclipse/LICENSE-APACHE) or <http://www.apache.org/licenses/LICENSE-2.0>)
- MIT license ([LICENSE-MIT](crates/mytheclipse/LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
at your option.
at your option.
+40
View File
@@ -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"] }
+33
View File
@@ -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();
```
+123
View File
@@ -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);
}
}
+73
View File
@@ -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;
+191
View File
@@ -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));
}
}
+139
View File
@@ -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);
}
}
+124
View File
@@ -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);
}
}
+77
View File
@@ -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"
);
}
}
+42
View File
@@ -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"
+201
View File
@@ -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.
+21
View File
@@ -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.
+49
View File
@@ -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());
}
});
```
+194
View File
@@ -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);
}
}
+32
View File
@@ -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 {}
+53
View File
@@ -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 {}
+348
View File
@@ -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(_))));
}
}
+37
View File
@@ -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 }
+201
View File
@@ -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.
+21
View File
@@ -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.
+36
View File
@@ -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");
```
+146
View File
@@ -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());
}
}
+96
View File
@@ -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"]);
}
}
+96
View File
@@ -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;
+166
View File
@@ -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$"));
}
}
+182
View File
@@ -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());
}
}
+43
View File
@@ -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"] }
+201
View File
@@ -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.
+21
View File
@@ -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.
+36
View File
@@ -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.
+163
View File
@@ -0,0 +1,163 @@
//! A RabbitMQ-backed [`EventBus`] (feature `amqp`), via the pure-Rust `lapin`
//! AMQP 0.9.1 client.
//!
//! Topics map to routing keys on a single topic [`lapin::ExchangeKind::Topic`]
//! exchange (default name `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");
}
}
+67
View File
@@ -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;
+147
View File
@@ -0,0 +1,147 @@
//! In-memory pub/sub dispatcher (feature `mem`), backed by
//! [`tokio::sync::broadcast`].
//!
//! Suitable for communication between components inside a single process
//! (monolith). Each topic lazily gets its own broadcast channel on first use.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use tokio::sync::broadcast;
use crate::traits::{EventBus, EventError, Subscription, SubscriptionInner};
const DEFAULT_CAPACITY: usize = 256;
/// An in-process [`EventBus`] backed by a `tokio::sync::broadcast` channel per
/// topic.
#[derive(Clone)]
pub struct InMemoryEventBus {
channels: Arc<Mutex<HashMap<String, broadcast::Sender<Vec<u8>>>>>,
capacity: usize,
}
impl Default for InMemoryEventBus {
fn default() -> Self {
Self::new(DEFAULT_CAPACITY)
}
}
impl InMemoryEventBus {
/// Builds a bus whose per-topic channels buffer up to `capacity` messages
/// for slow subscribers before older ones are dropped (lagged).
pub fn new(capacity: usize) -> Self {
Self {
channels: Arc::new(Mutex::new(HashMap::new())),
capacity,
}
}
fn sender_for(&self, topic: &str) -> broadcast::Sender<Vec<u8>> {
let mut channels = self.channels.lock().unwrap();
channels
.entry(topic.to_string())
.or_insert_with(|| broadcast::channel(self.capacity).0)
.clone()
}
/// The number of currently subscribed receivers for `topic` (0 if the
/// topic has never been touched or has no active subscribers).
pub fn subscriber_count(&self, topic: &str) -> usize {
self.channels
.lock()
.unwrap()
.get(topic)
.map(|tx| tx.receiver_count())
.unwrap_or(0)
}
}
#[async_trait]
impl EventBus for InMemoryEventBus {
async fn publish(&self, topic: &str, payload: Vec<u8>) -> Result<(), EventError> {
let tx = self.sender_for(topic);
// A `SendError` here just means there are currently no subscribers,
// which is not an error condition for a pub/sub bus.
let _ = tx.send(payload);
Ok(())
}
async fn subscribe(&self, topic: &str) -> Result<Subscription, EventError> {
let rx = self.sender_for(topic).subscribe();
Ok(Subscription::new(Box::new(BroadcastSubscription(rx))))
}
}
struct BroadcastSubscription(broadcast::Receiver<Vec<u8>>);
#[async_trait]
impl SubscriptionInner for BroadcastSubscription {
async fn recv(&mut self) -> Result<Vec<u8>, EventError> {
loop {
match self.0.recv().await {
Ok(payload) => return Ok(payload),
// A slow subscriber missed some messages; skip ahead rather
// than erroring, matching typical broadcast semantics.
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => return Err(EventError::Closed),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn publish_subscribe_roundtrip() {
let bus = InMemoryEventBus::default();
let mut sub = bus.subscribe("orders").await.unwrap();
bus.publish("orders", b"order-created".to_vec())
.await
.unwrap();
let msg = sub.recv().await.unwrap();
assert_eq!(msg, b"order-created");
}
#[tokio::test]
async fn multiple_subscribers_all_receive() {
let bus = InMemoryEventBus::default();
let mut sub_a = bus.subscribe("t").await.unwrap();
let mut sub_b = bus.subscribe("t").await.unwrap();
bus.publish("t", b"hello".to_vec()).await.unwrap();
assert_eq!(sub_a.recv().await.unwrap(), b"hello");
assert_eq!(sub_b.recv().await.unwrap(), b"hello");
}
#[tokio::test]
async fn publish_with_no_subscribers_is_ok() {
let bus = InMemoryEventBus::default();
assert!(bus.publish("nobody-listening", b"x".to_vec()).await.is_ok());
}
#[tokio::test]
async fn different_topics_are_isolated() {
let bus = InMemoryEventBus::default();
let mut sub = bus.subscribe("a").await.unwrap();
bus.publish("b", b"for-b".to_vec()).await.unwrap();
// Nothing arrives on `a`'s subscription; use a short timeout to prove it.
let result = tokio::time::timeout(std::time::Duration::from_millis(30), sub.recv()).await;
assert!(
result.is_err(),
"subscription on `a` should not receive `b`'s event"
);
}
#[tokio::test]
async fn subscriber_count_tracks_active_subscriptions() {
let bus = InMemoryEventBus::default();
assert_eq!(bus.subscriber_count("t"), 0);
let sub = bus.subscribe("t").await.unwrap();
assert_eq!(bus.subscriber_count("t"), 1);
drop(sub);
assert_eq!(bus.subscriber_count("t"), 0);
}
}
+95
View File
@@ -0,0 +1,95 @@
//! A NATS-backed [`EventBus`] (feature `nats`), via the pure-Rust
//! `async-nats` client.
//!
//! Topics map directly onto NATS subjects.
use async_trait::async_trait;
use futures_util::StreamExt;
use crate::traits::{EventBus, EventError, Subscription, SubscriptionInner};
fn map_connect_err(e: async_nats::ConnectError) -> EventError {
EventError::Io(e.to_string())
}
fn map_publish_err(e: async_nats::PublishError) -> EventError {
EventError::Io(e.to_string())
}
fn map_subscribe_err(e: async_nats::SubscribeError) -> EventError {
EventError::Io(e.to_string())
}
/// An [`EventBus`] backed by a NATS client.
#[derive(Clone)]
pub struct NatsEventBus {
client: async_nats::Client,
}
impl NatsEventBus {
/// Connects to a NATS server at `addr` (e.g. `nats://127.0.0.1:4222`).
pub async fn connect(addr: &str) -> Result<Self, EventError> {
let client = async_nats::connect(addr).await.map_err(map_connect_err)?;
Ok(Self { client })
}
/// Wraps an already-connected client.
pub fn from_client(client: async_nats::Client) -> Self {
Self { client }
}
}
#[async_trait]
impl EventBus for NatsEventBus {
async fn publish(&self, topic: &str, payload: Vec<u8>) -> Result<(), EventError> {
self.client
.publish(topic.to_string(), payload.into())
.await
.map_err(map_publish_err)
}
async fn subscribe(&self, topic: &str) -> Result<Subscription, EventError> {
let subscriber = self
.client
.subscribe(topic.to_string())
.await
.map_err(map_subscribe_err)?;
Ok(Subscription::new(Box::new(NatsSubscription { subscriber })))
}
}
struct NatsSubscription {
subscriber: async_nats::Subscriber,
}
#[async_trait]
impl SubscriptionInner for NatsSubscription {
async fn recv(&mut self) -> Result<Vec<u8>, EventError> {
match self.subscriber.next().await {
Some(msg) => Ok(msg.payload.to_vec()),
None => Err(EventError::Closed),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Requires a live NATS server at `NATS_URL` (e.g. `nats://127.0.0.1:4222`).
/// Run with: `NATS_URL=... cargo test -p 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");
}
}
+71
View File
@@ -0,0 +1,71 @@
//! The core [`EventBus`] trait and byte-oriented [`Subscription`] handle.
use async_trait::async_trait;
/// Errors returned by event bus operations.
#[derive(Debug)]
pub enum EventError {
/// The backend connection failed or was lost.
Io(String),
/// A value could not be serialized / deserialized.
Serialization(String),
/// The subscription's channel was closed (no more events will arrive).
Closed,
}
impl std::fmt::Display for EventError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(s) => write!(f, "event bus io: {s}"),
Self::Serialization(s) => write!(f, "event serialization: {s}"),
Self::Closed => write!(f, "subscription closed"),
}
}
}
impl std::error::Error for EventError {}
/// A byte-oriented publish/subscribe bus.
///
/// Implementations back this with an in-memory broadcast channel or a
/// distributed broker (RabbitMQ, NATS, ...). Topics are opaque strings;
/// backends may map them onto exchanges/subjects as appropriate.
#[async_trait]
pub trait EventBus: Send + Sync {
/// Publishes `payload` to `topic`. Whether publishing with zero
/// subscribers is an error is backend-defined (the in-memory backend
/// treats it as a no-op success).
async fn publish(&self, topic: &str, payload: Vec<u8>) -> Result<(), EventError>;
/// Subscribes to `topic`, returning a handle to receive future messages.
async fn subscribe(&self, topic: &str) -> Result<Subscription, EventError>;
}
/// A handle to receive messages from a subscribed topic.
///
/// Wraps the backend-specific receiver behind one `recv` method so callers
/// don't need to know which [`EventBus`] implementation produced it.
pub struct Subscription {
inner: Box<dyn SubscriptionInner>,
}
impl Subscription {
/// Wraps a backend-specific receiver.
///
/// Unused when no backend feature (`mem`/`amqp`/`nats`) is enabled.
#[allow(dead_code)]
pub(crate) fn new(inner: Box<dyn SubscriptionInner>) -> Self {
Self { inner }
}
/// Awaits the next message on this subscription.
pub async fn recv(&mut self) -> Result<Vec<u8>, EventError> {
self.inner.recv().await
}
}
/// Object-safe receiver behind [`Subscription`].
#[async_trait]
pub(crate) trait SubscriptionInner: Send {
async fn recv(&mut self) -> Result<Vec<u8>, EventError>;
}
+103
View File
@@ -0,0 +1,103 @@
//! JSON-typed convenience over the byte-oriented [`EventBus`].
use std::marker::PhantomData;
use std::sync::Arc;
use serde::de::DeserializeOwned;
use serde::Serialize;
use crate::traits::{EventBus, EventError, Subscription};
/// A marker trait for event payload types: anything JSON-(de)serializable.
pub trait Event: Serialize + DeserializeOwned + Send + Sync + 'static {}
impl<T> Event for T where T: Serialize + DeserializeOwned + Send + Sync + 'static {}
/// Wraps a byte-oriented [`EventBus`] with typed publish/subscribe using JSON
/// encoding.
#[derive(Clone)]
pub struct TypedEventBus<B> {
inner: Arc<B>,
}
impl<B: EventBus> TypedEventBus<B> {
/// Wraps `inner`.
pub fn new(inner: B) -> Self {
Self {
inner: Arc::new(inner),
}
}
/// Serializes and publishes `event` to `topic`.
pub async fn publish<E: Event>(&self, topic: &str, event: &E) -> Result<(), EventError> {
let bytes =
serde_json::to_vec(event).map_err(|e| EventError::Serialization(e.to_string()))?;
self.inner.publish(topic, bytes).await
}
/// Subscribes to `topic`, returning a [`TypedSubscription<E>`].
pub async fn subscribe<E: Event>(
&self,
topic: &str,
) -> Result<TypedSubscription<E>, EventError> {
let inner = self.inner.subscribe(topic).await?;
Ok(TypedSubscription {
inner,
_marker: PhantomData,
})
}
/// Returns the underlying byte-oriented bus.
pub fn inner(&self) -> &B {
&self.inner
}
}
/// A typed view over a byte [`Subscription`], deserializing each message.
pub struct TypedSubscription<E> {
inner: Subscription,
_marker: PhantomData<fn() -> E>,
}
impl<E: Event> TypedSubscription<E> {
/// Awaits and deserializes the next event.
pub async fn recv(&mut self) -> Result<E, EventError> {
let bytes = self.inner.recv().await?;
serde_json::from_slice(&bytes).map_err(|e| EventError::Serialization(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory::InMemoryEventBus;
use serde::Deserialize;
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct OrderCreated {
id: u64,
total_cents: u64,
}
#[tokio::test]
async fn typed_publish_subscribe_roundtrip() {
let bus = TypedEventBus::new(InMemoryEventBus::default());
let mut sub = bus.subscribe::<OrderCreated>("orders").await.unwrap();
bus.publish(
"orders",
&OrderCreated {
id: 1,
total_cents: 4999,
},
)
.await
.unwrap();
let event = sub.recv().await.unwrap();
assert_eq!(
event,
OrderCreated {
id: 1,
total_cents: 4999
}
);
}
}
+44
View File
@@ -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"
+201
View File
@@ -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.
+21
View File
@@ -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.

Some files were not shown because too many files have changed in this diff Show More