From 11e75f9bb4e252cfaca5d09789a64606ef367257 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 23 May 2026 22:34:24 +0200 Subject: [PATCH] feat(storage): add generic object storage adapter with CQRS traits, key validation, StorageConfig, and cargo-generate integration --- .gitignore | 1 + Cargo.lock | 563 +++++++++++++++++- Cargo.toml | 5 +- Cargo.toml.liquid | 3 + cargo-generate.toml | 24 + crates/adapters/storage/Cargo.toml | 21 + crates/adapters/storage/src/adapter.rs | 310 ++++++++++ crates/adapters/storage/src/config.rs | 90 +++ crates/adapters/storage/src/lib.rs | 5 + crates/bootstrap/Cargo.toml | 3 +- crates/bootstrap/Cargo.toml.liquid | 9 + crates/bootstrap/src/config.rs.liquid | 28 + crates/bootstrap/src/factory.rs | 11 +- crates/bootstrap/src/factory.rs.liquid | 62 ++ crates/domain/Cargo.toml | 2 + crates/domain/src/ports/mod.rs | 2 + crates/domain/src/ports/mod.rs.liquid | 7 + crates/domain/src/ports/storage.rs | 52 ++ .../src/handlers/storage_example.rs | 27 + crates/presentation/src/state.rs | 8 +- crates/presentation/src/state.rs.liquid | 28 + 21 files changed, 1246 insertions(+), 15 deletions(-) create mode 100644 crates/adapters/storage/Cargo.toml create mode 100644 crates/adapters/storage/src/adapter.rs create mode 100644 crates/adapters/storage/src/config.rs create mode 100644 crates/adapters/storage/src/lib.rs create mode 100644 crates/bootstrap/src/config.rs.liquid create mode 100644 crates/bootstrap/src/factory.rs.liquid create mode 100644 crates/domain/src/ports/mod.rs.liquid create mode 100644 crates/domain/src/ports/storage.rs create mode 100644 crates/presentation/src/handlers/storage_example.rs create mode 100644 crates/presentation/src/state.rs.liquid diff --git a/.gitignore b/.gitignore index 23fe3c9..6336341 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ data.db .idea/ .vscode/ **/dev.db +docs/ diff --git a/Cargo.lock b/Cargo.lock index 50fe469..342a9ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "adapters-storage" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "domain", + "futures", + "object_store", + "tokio", + "tracing", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -249,6 +263,7 @@ version = "0.1.0" dependencies = [ "adapters-auth", "adapters-sqlite", + "adapters-storage", "anyhow", "application", "axum", @@ -295,6 +310,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.42" @@ -334,6 +355,16 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -437,7 +468,9 @@ name = "domain" version = "0.1.0" dependencies = [ "async-trait", + "bytes", "chrono", + "futures", "serde", "thiserror", "uuid", @@ -503,6 +536,12 @@ dependencies = [ "spin", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -518,6 +557,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -562,6 +616,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -580,8 +645,10 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -619,9 +686,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] @@ -734,6 +822,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "hyper" version = "1.9.0" @@ -744,6 +838,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -752,6 +847,23 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", ] [[package]] @@ -760,13 +872,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -916,6 +1036,21 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1012,6 +1147,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1090,7 +1231,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -1131,12 +1272,49 @@ dependencies = [ "libm", ] +[[package]] +name = "object_store" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cfccb68961a56facde1163f9319e0d15743352344e7808a11795fb99698dcaf" +dependencies = [ + "async-trait", + "base64", + "bytes", + "chrono", + "futures", + "humantime", + "hyper", + "itertools", + "md-5", + "parking_lot", + "percent-encoding", + "quick-xml", + "rand 0.8.6", + "reqwest", + "ring", + "rustls-pemfile", + "serde", + "serde_json", + "snafu", + "tokio", + "tracing", + "url", + "walkdir", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "parking" version = "2.2.1" @@ -1282,6 +1460,71 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.42" @@ -1304,8 +1547,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1315,7 +1568,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1327,6 +1590,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1374,6 +1646,48 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -1401,13 +1715,75 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", "zeroize", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1420,12 +1796,53 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.228" @@ -1536,7 +1953,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1566,6 +1983,27 @@ dependencies = [ "serde", ] +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "socket2" version = "0.6.3" @@ -1712,7 +2150,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.6", "rsa", "serde", "sha1", @@ -1752,7 +2190,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.6", "serde", "serde_json", "sha2", @@ -1830,6 +2268,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -1953,6 +2394,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -1964,6 +2415,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" @@ -1988,12 +2452,15 @@ checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "bitflags", "bytes", + "futures-util", "http", "http-body", "pin-project-lite", + "tower", "tower-layer", "tower-service", "tracing", + "url", ] [[package]] @@ -2070,6 +2537,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.0" @@ -2194,6 +2667,25 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2228,6 +2720,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.106" @@ -2260,6 +2765,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "whoami" version = "1.6.1" @@ -2270,6 +2808,15 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index d77b114..2e9b60d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/adapters/sqlite", "crates/adapters/postgres", "crates/adapters/auth", + "crates/adapters/storage", "crates/presentation", "crates/bootstrap", "crates/worker", @@ -16,6 +17,7 @@ resolver = "2" tokio = { version = "1.0", features = ["macros", "rt-multi-thread", "net", "time", "sync"] } async-trait = "0.1" futures = "0.3" +bytes = "1.0" anyhow = "1.0" thiserror = "2.0" serde = { version = "1.0", features = ["derive"] } @@ -35,5 +37,6 @@ utoipa-scalar = { version = "0.3", features = ["axum"] } domain = { path = "crates/domain" } application = { path = "crates/application" } api-types = { path = "crates/api-types" } -adapters-auth = { path = "crates/adapters/auth" } +adapters-auth = { path = "crates/adapters/auth" } +adapters-storage = { path = "crates/adapters/storage" } presentation = { path = "crates/presentation" } diff --git a/Cargo.toml.liquid b/Cargo.toml.liquid index 48f8dca..f0eb835 100644 --- a/Cargo.toml.liquid +++ b/Cargo.toml.liquid @@ -6,6 +6,7 @@ members = [ {% if database == "sqlite" %}"crates/adapters/sqlite",{% endif %} {% if database == "postgres" %}"crates/adapters/postgres",{% endif %} "crates/adapters/auth", + {% if storage %}"crates/adapters/storage",{% endif %} "crates/presentation", "crates/bootstrap", {% if worker %}"crates/worker",{% endif %} @@ -16,6 +17,7 @@ resolver = "2" tokio = { version = "1.0", features = ["macros", "rt-multi-thread", "net", "time", "sync"] } async-trait = "0.1" futures = "0.3" +bytes = "1.0" anyhow = "1.0" thiserror = "2.0" serde = { version = "1.0", features = ["derive"] } @@ -36,4 +38,5 @@ domain = { path = "crates/domain" } application = { path = "crates/application" } api-types = { path = "crates/api-types" } adapters-auth = { path = "crates/adapters/auth" } +{% if storage %}adapters-storage = { path = "crates/adapters/storage" }{% endif %} presentation = { path = "crates/presentation" } diff --git a/cargo-generate.toml b/cargo-generate.toml index e05fdb7..7d2722f 100644 --- a/cargo-generate.toml +++ b/cargo-generate.toml @@ -33,3 +33,27 @@ ignore = ["crates/worker"] [conditional.'!auth_oidc'] ignore = ["crates/adapters/auth/src/oidc.rs"] + +[placeholders.storage] +type = "bool" +prompt = "Include object storage adapter (local/S3/GCS)?" +default = false + +[placeholders.storage_s3] +type = "bool" +prompt = "Include S3/MinIO backend?" +default = false +if = "storage" + +[placeholders.storage_gcs] +type = "bool" +prompt = "Include GCS backend?" +default = false +if = "storage" + +[conditional.'!storage'] +ignore = [ + "crates/adapters/storage", + "crates/domain/src/ports/storage.rs", + "crates/presentation/src/handlers/storage_example.rs", +] diff --git a/crates/adapters/storage/Cargo.toml b/crates/adapters/storage/Cargo.toml new file mode 100644 index 0000000..f33b3f1 --- /dev/null +++ b/crates/adapters/storage/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "adapters-storage" +version = "0.1.0" +edition = "2024" + +[features] +default = [] +s3 = ["object_store/aws"] +gcs = ["object_store/gcp"] + +[dependencies] +domain = { workspace = true } +async-trait = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +bytes = { workspace = true } +futures = { workspace = true } +object_store = { version = "0.11" } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/adapters/storage/src/adapter.rs b/crates/adapters/storage/src/adapter.rs new file mode 100644 index 0000000..8facae3 --- /dev/null +++ b/crates/adapters/storage/src/adapter.rs @@ -0,0 +1,310 @@ +use std::sync::Arc; +use async_trait::async_trait; +use bytes::Bytes; +use futures::stream::StreamExt; +use object_store::{ObjectStore, path::Path, Error as OsError}; +use domain::errors::DomainError; +use domain::ports::{DataStream, StorageReader, StorageWriter}; + +pub struct ObjectStorageAdapter { + store: Arc, + prefix: String, +} + +impl ObjectStorageAdapter { + pub fn new(store: Arc, prefix: impl Into) -> Result { + let prefix = prefix.into(); + if !prefix.is_empty() { + validate_key(&prefix)?; + } + Ok(Self { store, prefix }) + } + + fn path(&self, key: &str) -> Path { + if self.prefix.is_empty() { + Path::from(key) + } else { + Path::from(format!("{}/{key}", self.prefix)) + } + } +} + +fn map_err(e: OsError, key: &str) -> DomainError { + match e { + OsError::NotFound { .. } => DomainError::NotFound(key.to_string()), + e => DomainError::Internal(e.to_string()), + } +} + +fn validate_key(key: &str) -> Result<(), DomainError> { + if key.is_empty() { + return Err(DomainError::Validation("storage key must not be empty".into())); + } + if key.starts_with('/') { + return Err(DomainError::Validation( + format!("storage key must not start with '/': {key}"), + )); + } + if key.split('/').any(|seg| seg == ".." || seg == ".") { + return Err(DomainError::Validation( + format!("storage key contains invalid path segment: {key}"), + )); + } + Ok(()) +} + +#[async_trait] +impl StorageWriter for ObjectStorageAdapter { + async fn put(&self, key: &str, data: DataStream) -> Result<(), DomainError> { + validate_key(key)?; + let path = self.path(key); + let mut upload = self + .store + .put_multipart(&path) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + + let mut stream = data; + while let Some(result) = stream.next().await { + match result { + Ok(bytes) => { + if let Err(e) = upload.put_part(bytes.into()).await { + let _ = upload.abort().await; + return Err(DomainError::Internal(e.to_string())); + } + } + Err(e) => { + let _ = upload.abort().await; + return Err(e); + } + } + } + upload.complete().await.map_err(|e| DomainError::Internal(e.to_string()))?; + Ok(()) + } + + async fn delete(&self, key: &str) -> Result<(), DomainError> { + validate_key(key)?; + let path = self.path(key); + match self.store.delete(&path).await { + Ok(()) => Ok(()), + Err(OsError::NotFound { .. }) => Ok(()), + Err(e) => Err(DomainError::Internal(e.to_string())), + } + } +} + +#[async_trait] +impl StorageReader for ObjectStorageAdapter { + async fn get(&self, key: &str) -> Result { + validate_key(key)?; + let path = self.path(key); + let result = self + .store + .get(&path) + .await + .map_err(|e| map_err(e, key))?; + let s = result + .into_stream() + .map(|r| r.map_err(|e| DomainError::Internal(e.to_string()))); + Ok(Box::pin(s)) + } + + async fn list(&self, prefix: Option<&str>) -> Result, DomainError> { + if let Some(p) = prefix { + validate_key(p)?; + } + let list_prefix = match (prefix, self.prefix.is_empty()) { + (Some(p), false) => Some(Path::from(format!("{}/{p}", self.prefix))), + (Some(p), true) => Some(Path::from(p)), + (None, false) => Some(Path::from(self.prefix.as_str())), + (None, true) => None, + }; + + let mut result = Vec::new(); + let mut stream = self.store.list(list_prefix.as_ref()); + while let Some(meta) = stream.next().await { + let meta = meta.map_err(|e| DomainError::Internal(e.to_string()))?; + let key = meta.location.to_string(); + let stripped = if !self.prefix.is_empty() { + key.strip_prefix(&format!("{}/", self.prefix)) + .ok_or_else(|| DomainError::Internal(format!( + "listed key '{key}' does not start with expected prefix '{}'", + self.prefix + )))? + .to_string() + } else { + key + }; + result.push(stripped); + } + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::ports::{StorageReader, StorageWriter}; + use futures::stream; + use object_store::memory::InMemory; + + fn make_adapter() -> ObjectStorageAdapter { + ObjectStorageAdapter::new(Arc::new(InMemory::new()), "test").unwrap() + } + + fn one_shot(data: &'static [u8]) -> DataStream { + Box::pin(stream::once(async move { Ok(Bytes::from(data)) })) + } + + #[tokio::test] + async fn put_get_roundtrip() { + let a = make_adapter(); + a.put("hello.txt", one_shot(b"world")).await.unwrap(); + let mut s = a.get("hello.txt").await.unwrap(); + let mut out = Vec::new(); + while let Some(chunk) = s.next().await { + out.extend_from_slice(&chunk.unwrap()); + } + assert_eq!(out, b"world"); + } + + #[tokio::test] + async fn get_missing_is_not_found() { + let a = make_adapter(); + assert!(matches!(a.get("nope.txt").await, Err(DomainError::NotFound(_)))); + } + + #[tokio::test] + async fn delete_is_idempotent() { + let a = make_adapter(); + a.delete("nope.txt").await.unwrap(); + } + + #[tokio::test] + async fn delete_removes_key() { + let a = make_adapter(); + a.put("file.txt", one_shot(b"data")).await.unwrap(); + a.delete("file.txt").await.unwrap(); + assert!(matches!(a.get("file.txt").await, Err(DomainError::NotFound(_)))); + } + + #[tokio::test] + async fn list_returns_keys_under_prefix() { + let a = make_adapter(); + a.put("docs/readme.txt", one_shot(b"x")).await.unwrap(); + a.put("docs/guide.txt", one_shot(b"y")).await.unwrap(); + a.put("other/file.txt", one_shot(b"z")).await.unwrap(); + let keys = a.list(Some("docs")).await.unwrap(); + assert_eq!(keys.len(), 2); + assert!(keys.iter().any(|k| k.ends_with("readme.txt"))); + assert!(keys.iter().any(|k| k.ends_with("guide.txt"))); + } + + #[tokio::test] + async fn list_none_returns_all() { + let a = make_adapter(); + a.put("a.txt", one_shot(b"1")).await.unwrap(); + a.put("b.txt", one_shot(b"2")).await.unwrap(); + let keys = a.list(None).await.unwrap(); + assert_eq!(keys.len(), 2); + } + + #[tokio::test] + async fn rejects_empty_key() { + let a = make_adapter(); + assert!(matches!(a.put("", one_shot(b"x")).await, Err(DomainError::Validation(_)))); + assert!(matches!(a.get("").await, Err(DomainError::Validation(_)))); + assert!(matches!(a.delete("").await, Err(DomainError::Validation(_)))); + } + + #[tokio::test] + async fn rejects_absolute_key() { + let a = make_adapter(); + assert!(matches!( + a.put("/etc/passwd", one_shot(b"x")).await, + Err(DomainError::Validation(_)) + )); + } + + #[tokio::test] + async fn rejects_path_traversal() { + let a = make_adapter(); + assert!(matches!(a.get("../escape").await, Err(DomainError::Validation(_)))); + assert!(matches!(a.get("a/../../../etc").await, Err(DomainError::Validation(_)))); + } + + #[tokio::test] + async fn rejects_dot_segment() { + let a = make_adapter(); + assert!(matches!( + a.put("./file.txt", one_shot(b"x")).await, + Err(DomainError::Validation(_)) + )); + } + + #[tokio::test] + async fn rejects_invalid_list_prefix() { + let a = make_adapter(); + assert!(matches!(a.list(Some("")).await, Err(DomainError::Validation(_)))); + assert!(matches!(a.list(Some("../escape")).await, Err(DomainError::Validation(_)))); + } + + #[tokio::test] + async fn put_overwrites_existing() { + let a = make_adapter(); + a.put("file.txt", one_shot(b"version1")).await.unwrap(); + a.put("file.txt", one_shot(b"version2")).await.unwrap(); + let mut s = a.get("file.txt").await.unwrap(); + let mut out = Vec::new(); + while let Some(chunk) = s.next().await { + out.extend_from_slice(&chunk.unwrap()); + } + assert_eq!(out, b"version2"); + } + + #[tokio::test] + async fn list_returns_exact_key_paths() { + let a = make_adapter(); + a.put("docs/readme.txt", one_shot(b"x")).await.unwrap(); + let mut keys = a.list(Some("docs")).await.unwrap(); + keys.sort(); + assert_eq!(keys, vec!["docs/readme.txt"]); + } + + #[tokio::test] + async fn put_bytes_get_bytes_roundtrip() { + let a = make_adapter(); + a.put_bytes("data.bin", Bytes::from("hello bytes")).await.unwrap(); + let got = a.get_bytes("data.bin").await.unwrap(); + assert_eq!(got.as_ref(), b"hello bytes"); + } + + #[tokio::test] + async fn get_bytes_missing_is_not_found() { + let a = make_adapter(); + assert!(matches!(a.get_bytes("nope.bin").await, Err(DomainError::NotFound(_)))); + } + + #[test] + fn new_rejects_traversal_prefix() { + let result = ObjectStorageAdapter::new(Arc::new(InMemory::new()), "../evil"); + assert!(matches!(result, Err(DomainError::Validation(_)))); + } + + #[test] + fn new_rejects_absolute_prefix() { + let result = ObjectStorageAdapter::new(Arc::new(InMemory::new()), "/root"); + assert!(matches!(result, Err(DomainError::Validation(_)))); + } + + #[test] + fn new_accepts_empty_prefix() { + assert!(ObjectStorageAdapter::new(Arc::new(InMemory::new()), "").is_ok()); + } + + #[test] + fn new_accepts_valid_prefix() { + assert!(ObjectStorageAdapter::new(Arc::new(InMemory::new()), "my-bucket/data").is_ok()); + } +} diff --git a/crates/adapters/storage/src/config.rs b/crates/adapters/storage/src/config.rs new file mode 100644 index 0000000..8263134 --- /dev/null +++ b/crates/adapters/storage/src/config.rs @@ -0,0 +1,90 @@ +use std::sync::Arc; +use anyhow::{Context, Result}; +use object_store::ObjectStore; +use object_store::local::LocalFileSystem; + +/// All storage configuration. Populate once via `from_env()` and pass +/// explicitly to `build_store` and `ObjectStorageAdapter::new`. +#[derive(Debug, Clone)] +pub struct StorageConfig { + pub backend: String, + pub prefix: String, + // local backend: + pub local_path: Option, + // s3/minio backend: + pub s3_endpoint: Option, + pub s3_access_key_id: Option, + pub s3_secret_access_key: Option, + pub s3_bucket: Option, + pub s3_region: Option, + // gcs backend: + pub gcs_bucket: Option, +} + +impl StorageConfig { + pub fn from_env() -> Result { + Ok(Self { + backend: std::env::var("STORAGE_BACKEND") + .context("STORAGE_BACKEND must be set (local, s3, gcs)")?, + prefix: std::env::var("STORAGE_PREFIX").unwrap_or_default(), + local_path: std::env::var("STORAGE_PATH").ok(), + s3_endpoint: std::env::var("S3_ENDPOINT").ok(), + s3_access_key_id: std::env::var("S3_ACCESS_KEY_ID").ok(), + s3_secret_access_key: std::env::var("S3_SECRET_ACCESS_KEY").ok(), + s3_bucket: std::env::var("S3_BUCKET").ok(), + s3_region: std::env::var("S3_REGION").ok(), + gcs_bucket: std::env::var("GCS_BUCKET").ok(), + }) + } +} + +pub fn build_store(config: &StorageConfig) -> Result> { + match config.backend.as_str() { + "local" => { + let path = config.local_path.as_deref() + .context("STORAGE_PATH must be set when STORAGE_BACKEND=local")?; + std::fs::create_dir_all(path) + .with_context(|| format!("failed to create storage dir: {path}"))?; + let store = LocalFileSystem::new_with_prefix(path)?; + Ok(Arc::new(store)) + } + #[cfg(feature = "s3")] + "s3" => { + use object_store::aws::AmazonS3Builder; + let store = AmazonS3Builder::new() + .with_endpoint( + config.s3_endpoint.as_deref().context("S3_ENDPOINT must be set")?, + ) + .with_access_key_id( + config.s3_access_key_id.as_deref() + .context("S3_ACCESS_KEY_ID must be set")?, + ) + .with_secret_access_key( + config.s3_secret_access_key.as_deref() + .context("S3_SECRET_ACCESS_KEY must be set")?, + ) + .with_bucket_name( + config.s3_bucket.as_deref().context("S3_BUCKET must be set")?, + ) + .with_region(config.s3_region.as_deref().unwrap_or("us-east-1")) + .with_allow_http(true) + .build()?; + Ok(Arc::new(store)) + } + #[cfg(feature = "gcs")] + "gcs" => { + use object_store::gcp::GoogleCloudStorageBuilder; + let store = GoogleCloudStorageBuilder::new() + .with_bucket_name( + config.gcs_bucket.as_deref().context("GCS_BUCKET must be set")?, + ) + .build()?; + Ok(Arc::new(store)) + } + other => anyhow::bail!( + "unknown STORAGE_BACKEND={other:?}; compiled features: local{}{}", + if cfg!(feature = "s3") { ", s3" } else { "" }, + if cfg!(feature = "gcs") { ", gcs" } else { "" }, + ), + } +} diff --git a/crates/adapters/storage/src/lib.rs b/crates/adapters/storage/src/lib.rs new file mode 100644 index 0000000..f32d0d1 --- /dev/null +++ b/crates/adapters/storage/src/lib.rs @@ -0,0 +1,5 @@ +pub mod adapter; +pub mod config; + +pub use adapter::ObjectStorageAdapter; +pub use config::{build_store, StorageConfig}; diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml index 1325656..458f064 100644 --- a/crates/bootstrap/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -10,7 +10,8 @@ path = "src/main.rs" [dependencies] domain = { workspace = true } application = { workspace = true } -adapters-auth = { workspace = true } +adapters-auth = { workspace = true } +adapters-storage = { workspace = true } presentation = { workspace = true } adapters-sqlite = { path = "../adapters/sqlite" } tokio = { workspace = true } diff --git a/crates/bootstrap/Cargo.toml.liquid b/crates/bootstrap/Cargo.toml.liquid index abeeb73..10ec55b 100644 --- a/crates/bootstrap/Cargo.toml.liquid +++ b/crates/bootstrap/Cargo.toml.liquid @@ -11,6 +11,15 @@ path = "src/main.rs" domain = { workspace = true } application = { workspace = true } adapters-auth = { workspace = true } +{% if storage and storage_s3 and storage_gcs %} +adapters-storage = { workspace = true, features = ["s3", "gcs"] } +{% elsif storage and storage_s3 %} +adapters-storage = { workspace = true, features = ["s3"] } +{% elsif storage and storage_gcs %} +adapters-storage = { workspace = true, features = ["gcs"] } +{% elsif storage %} +adapters-storage = { workspace = true } +{% endif %} presentation = { workspace = true } {% if database == "sqlite" %} adapters-sqlite = { path = "../adapters/sqlite" } diff --git a/crates/bootstrap/src/config.rs.liquid b/crates/bootstrap/src/config.rs.liquid new file mode 100644 index 0000000..6b8ee02 --- /dev/null +++ b/crates/bootstrap/src/config.rs.liquid @@ -0,0 +1,28 @@ +#[derive(Debug, Clone)] +pub struct Config { + pub host: String, + pub port: u16, + pub database_url: String, + pub jwt_secret: String, + pub cors_allowed_origins: Vec, +} + +impl Config { + pub fn from_env() -> Self { + dotenvy::dotenv().ok(); + Self { + host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()), + port: std::env::var("PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(3000), + database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"), + jwt_secret: std::env::var("JWT_SECRET").expect("JWT_SECRET must be set"), + cors_allowed_origins: std::env::var("CORS_ALLOWED_ORIGINS") + .unwrap_or_else(|_| "http://localhost:3000".to_string()) + .split(',') + .map(|s| s.trim().to_string()) + .collect(), + } + } +} diff --git a/crates/bootstrap/src/factory.rs b/crates/bootstrap/src/factory.rs index 737d43e..e1d825a 100644 --- a/crates/bootstrap/src/factory.rs +++ b/crates/bootstrap/src/factory.rs @@ -1,5 +1,3 @@ -// If you chose postgres at cargo generate time, replace adapters_sqlite with -// adapters_postgres throughout this file (connect, run_migrations, PostgresUserRepository). use std::sync::Arc; use anyhow::Result; use axum::Router; @@ -8,6 +6,7 @@ use tower_http::{cors::{Any, CorsLayer}, trace::TraceLayer}; use adapters_auth::{BcryptPasswordHasher, JwtTokenIssuer}; use adapters_sqlite::{connect, run_migrations, SqliteUserRepository}; +use adapters_storage::{ObjectStorageAdapter, StorageConfig, build_store}; use application::use_cases::{GetProfile, LoginUser, RegisterUser}; use presentation::{routes::app_router, state::AppState}; @@ -25,7 +24,13 @@ pub async fn build_app(config: &Config) -> Result { let login_uc = Arc::new(LoginUser::new(user_repo.clone(), hasher, issuer.clone())); let get_profile_uc = Arc::new(GetProfile::new(user_repo)); - let state = AppState::new(register_uc, login_uc, get_profile_uc, issuer); + let storage_cfg = StorageConfig::from_env()?; + let store = build_store(&storage_cfg)?; + // To inject storage into a use case, clone it into the constructor: + // let my_uc = Arc::new(MyUseCase::new(repo, storage.clone())); + let storage = Arc::new(ObjectStorageAdapter::new(store, &storage_cfg.prefix)?); + + let state = AppState::new(register_uc, login_uc, get_profile_uc, issuer, storage); let cors = CorsLayer::new() .allow_origin( diff --git a/crates/bootstrap/src/factory.rs.liquid b/crates/bootstrap/src/factory.rs.liquid new file mode 100644 index 0000000..8a19bbe --- /dev/null +++ b/crates/bootstrap/src/factory.rs.liquid @@ -0,0 +1,62 @@ +use std::sync::Arc; +use anyhow::Result; +use axum::Router; +use axum::http::HeaderValue; +use tower_http::{cors::{Any, CorsLayer}, trace::TraceLayer}; + +use adapters_auth::{BcryptPasswordHasher, JwtTokenIssuer}; +{% if database == "sqlite" %} +use adapters_sqlite::{connect, run_migrations, SqliteUserRepository}; +{% endif %} +{% if database == "postgres" %} +use adapters_postgres::{connect, run_migrations, PostgresUserRepository}; +{% endif %} +{% if storage %} +use adapters_storage::{ObjectStorageAdapter, StorageConfig, build_store}; +{% endif %} +use application::use_cases::{GetProfile, LoginUser, RegisterUser}; +use presentation::{routes::app_router, state::AppState}; + +use crate::config::Config; + +pub async fn build_app(config: &Config) -> Result { + let pool = connect(&config.database_url).await?; + run_migrations(&pool).await?; + + {% if database == "sqlite" %} + let user_repo = Arc::new(SqliteUserRepository::new(pool)); + {% endif %} + {% if database == "postgres" %} + let user_repo = Arc::new(PostgresUserRepository::new(pool)); + {% endif %} + let hasher = Arc::new(BcryptPasswordHasher); + let issuer = Arc::new(JwtTokenIssuer::new(&config.jwt_secret)); + + let register_uc = Arc::new(RegisterUser::new(user_repo.clone(), hasher.clone())); + let login_uc = Arc::new(LoginUser::new(user_repo.clone(), hasher, issuer.clone())); + let get_profile_uc = Arc::new(GetProfile::new(user_repo)); + + {% if storage %} + let storage_cfg = StorageConfig::from_env()?; + let store = build_store(&storage_cfg)?; + // To inject storage into a use case, clone it into the constructor: + // let my_uc = Arc::new(MyUseCase::new(repo, storage.clone())); + let storage = Arc::new(ObjectStorageAdapter::new(store, &storage_cfg.prefix)?); + {% endif %} + + let state = AppState::new(register_uc, login_uc, get_profile_uc, issuer{% if storage %}, storage{% endif %}); + + let cors = CorsLayer::new() + .allow_origin( + config.cors_allowed_origins.iter() + .filter_map(|o| o.parse::().ok()) + .collect::>(), + ) + .allow_methods(Any) + .allow_headers(Any); + + Ok(app_router() + .with_state(state) + .layer(TraceLayer::new_for_http()) + .layer(cors)) +} diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index 77b1fd0..fc590b6 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -9,3 +9,5 @@ chrono = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } +bytes = { workspace = true } +futures = { workspace = true } diff --git a/crates/domain/src/ports/mod.rs b/crates/domain/src/ports/mod.rs index aa20e49..2370d49 100644 --- a/crates/domain/src/ports/mod.rs +++ b/crates/domain/src/ports/mod.rs @@ -1,5 +1,7 @@ mod auth; +mod storage; mod user_repo; pub use auth::{PasswordHasher, TokenIssuer}; +pub use storage::{DataStream, StoragePort, StorageReader, StorageWriter}; pub use user_repo::UserRepository; diff --git a/crates/domain/src/ports/mod.rs.liquid b/crates/domain/src/ports/mod.rs.liquid new file mode 100644 index 0000000..161b9f0 --- /dev/null +++ b/crates/domain/src/ports/mod.rs.liquid @@ -0,0 +1,7 @@ +mod auth; +{% if storage %}mod storage;{% endif %} +mod user_repo; + +pub use auth::{PasswordHasher, TokenIssuer}; +{% if storage %}pub use storage::{DataStream, StoragePort, StorageReader, StorageWriter};{% endif %} +pub use user_repo::UserRepository; diff --git a/crates/domain/src/ports/storage.rs b/crates/domain/src/ports/storage.rs new file mode 100644 index 0000000..0a77deb --- /dev/null +++ b/crates/domain/src/ports/storage.rs @@ -0,0 +1,52 @@ +use async_trait::async_trait; +use bytes::Bytes; +use futures::stream::{self, BoxStream, StreamExt}; +use crate::errors::DomainError; + +pub type DataStream = BoxStream<'static, Result>; + +/// Read operations on object storage. Keys are full paths relative to the adapter root. +#[async_trait] +pub trait StorageReader: Send + Sync { + /// Returns the content of `key` as a stream. Returns `DomainError::NotFound` if absent. + async fn get(&self, key: &str) -> Result; + + /// Lists all keys whose path begins with `prefix`, or all keys when `prefix` is `None`. + /// Returned keys are **full paths from the adapter root**, not relative to `prefix`. + /// Example: `list(Some("docs"))` returns `["docs/readme.txt"]`, not `["readme.txt"]`. + async fn list(&self, prefix: Option<&str>) -> Result, DomainError>; + + /// Convenience: reads the entire content of `key` into memory. Wraps `get`. + async fn get_bytes(&self, key: &str) -> Result { + let mut stream = self.get(key).await?; + let mut buf: Vec = Vec::new(); + while let Some(chunk) = stream.next().await { + buf.extend_from_slice(&chunk?); + } + Ok(Bytes::from(buf)) + } +} + +/// Write operations on object storage. +#[async_trait] +pub trait StorageWriter: Send + Sync { + /// Stores `data` at `key`. Overwrites any existing content at that key silently. + async fn put(&self, key: &str, data: DataStream) -> Result<(), DomainError>; + + /// Deletes `key`. Returns `Ok(())` even if the key does not exist (idempotent). + async fn delete(&self, key: &str) -> Result<(), DomainError>; + + /// Convenience: stores an in-memory buffer at `key`. Wraps `put`. + async fn put_bytes(&self, key: &str, data: Bytes) -> Result<(), DomainError> { + self.put(key, Box::pin(stream::once(async move { Ok(data) }))).await + } +} + +/// Combined read + write storage interface. +/// +/// **Usage note:** `Arc` is the intended DI type everywhere. +/// `StorageReader` and `StorageWriter` exist for implementation clarity, but Rust does not +/// support narrowing `Arc` to `Arc` at runtime. +/// Inject `Arc` into constructors and pass `.clone()` from the factory. +pub trait StoragePort: StorageReader + StorageWriter {} +impl StoragePort for T {} diff --git a/crates/presentation/src/handlers/storage_example.rs b/crates/presentation/src/handlers/storage_example.rs new file mode 100644 index 0000000..19aac8a --- /dev/null +++ b/crates/presentation/src/handlers/storage_example.rs @@ -0,0 +1,27 @@ +// Example: stream a stored file as an HTTP response. +// Remove this file or replace with your own handlers. +// +// To use, add to your router: +// .route("/files/*key", get(storage_example::get_file)) +// +// use axum::{ +// body::Body, +// extract::{Path, State}, +// http::StatusCode, +// response::IntoResponse, +// }; +// use futures::StreamExt; +// use crate::state::AppState; +// +// pub async fn get_file( +// Path(key): Path, +// State(state): State, +// ) -> Result { +// let stream = state +// .storage +// .get(&key) +// .await +// .map_err(|_| StatusCode::NOT_FOUND)?; +// let body = Body::from_stream(stream.map(|r| r.map_err(|e| e.to_string()))); +// Ok(body) +// } diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs index 0296007..37e9a9c 100644 --- a/crates/presentation/src/state.rs +++ b/crates/presentation/src/state.rs @@ -1,6 +1,6 @@ use std::sync::Arc; use application::use_cases::{GetProfile, LoginUser, RegisterUser}; -use domain::ports::TokenIssuer; +use domain::ports::{StoragePort, TokenIssuer}; #[derive(Clone)] pub struct AppState { @@ -8,6 +8,9 @@ pub struct AppState { pub login_uc: Arc, pub get_profile_uc: Arc, pub token_issuer: Arc, + /// Direct storage access for handlers. Use cases that need storage should receive + /// `Arc` in their own constructor rather than reading it from `AppState`. + pub storage: Arc, } impl AppState { @@ -16,7 +19,8 @@ impl AppState { login_uc: Arc, get_profile_uc: Arc, token_issuer: Arc, + storage: Arc, ) -> Self { - Self { register_uc, login_uc, get_profile_uc, token_issuer } + Self { register_uc, login_uc, get_profile_uc, token_issuer, storage } } } diff --git a/crates/presentation/src/state.rs.liquid b/crates/presentation/src/state.rs.liquid new file mode 100644 index 0000000..e30ef1d --- /dev/null +++ b/crates/presentation/src/state.rs.liquid @@ -0,0 +1,28 @@ +use std::sync::Arc; +use application::use_cases::{GetProfile, LoginUser, RegisterUser}; +{% if storage %} +use domain::ports::{StoragePort, TokenIssuer}; +{% else %} +use domain::ports::TokenIssuer; +{% endif %} + +#[derive(Clone)] +pub struct AppState { + pub register_uc: Arc, + pub login_uc: Arc, + pub get_profile_uc: Arc, + pub token_issuer: Arc, + {% if storage %}pub storage: Arc,{% endif %} +} + +impl AppState { + pub fn new( + register_uc: Arc, + login_uc: Arc, + get_profile_uc: Arc, + token_issuer: Arc, + {% if storage %}storage: Arc,{% endif %} + ) -> Self { + Self { register_uc, login_uc, get_profile_uc, token_issuer{% if storage %}, storage{% endif %} } + } +}