Compare commits
6 Commits
1c5ae5d239
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 11e75f9bb4 | |||
| 3fa46a4d58 | |||
| 6dc9b26dfc | |||
| 6183262ed5 | |||
| e3552fd50d | |||
| 5b0d5bf15d |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,3 +1,10 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
.env
|
||||
*.db
|
||||
data.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.idea/
|
||||
.vscode/
|
||||
**/dev.db
|
||||
docs/
|
||||
|
||||
573
Cargo.lock
generated
573
Cargo.lock
generated
@@ -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"
|
||||
@@ -2486,6 +3033,16 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
|
||||
[[package]]
|
||||
name = "worker"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"adapters-sqlite",
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"domain",
|
||||
"dotenvy",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"] }
|
||||
@@ -31,9 +33,10 @@ sqlx = { version = "0.8", features = ["runtime-tokio", "uuid", "chrono"
|
||||
jsonwebtoken = "9.3"
|
||||
bcrypt = "0.15"
|
||||
utoipa = { version = "5.3", features = ["axum_extras", "uuid", "chrono"] }
|
||||
utoipa-scalar = { version = "5.0", features = ["axum"] }
|
||||
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" }
|
||||
{% if storage %}adapters-storage = { path = "crates/adapters/storage" }{% endif %}
|
||||
presentation = { path = "crates/presentation" }
|
||||
|
||||
61
Dockerfile
61
Dockerfile
@@ -1,27 +1,54 @@
|
||||
FROM rust:1.92 AS builder
|
||||
# ----- build -----
|
||||
FROM rust:slim-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
WORKDIR /build
|
||||
|
||||
# Build the release binary
|
||||
RUN cargo build --release -p api
|
||||
# Copy manifests + lockfile first so cargo can fetch deps as a cached layer.
|
||||
# Source changes below won't invalidate this layer.
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/domain/Cargo.toml crates/domain/Cargo.toml
|
||||
COPY crates/application/Cargo.toml crates/application/Cargo.toml
|
||||
COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml
|
||||
COPY crates/adapters/sqlite/Cargo.toml crates/adapters/sqlite/Cargo.toml
|
||||
COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Cargo.toml
|
||||
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
|
||||
COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml
|
||||
COPY crates/bootstrap/Cargo.toml crates/bootstrap/Cargo.toml
|
||||
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
|
||||
|
||||
# Stub every crate so cargo can resolve and fetch deps without real source
|
||||
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \
|
||||
xargs -I{} sh -c 'mkdir -p {}/src && echo "fn main(){}" > {}/src/main.rs && printf "" > {}/src/lib.rs'
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN cargo fetch
|
||||
|
||||
# Copy sqlx offline query cache — no live database needed at compile time
|
||||
COPY crates/adapters/sqlite/.sqlx ./crates/adapters/sqlite/.sqlx
|
||||
|
||||
# Now copy real source — only invalidates cache on source changes
|
||||
COPY crates ./crates
|
||||
|
||||
ENV SQLX_OFFLINE=true
|
||||
RUN cargo build --release -p bootstrap -p worker
|
||||
|
||||
# ----- runtime -----
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libssl3 wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install OpenSSL (required for many Rust networking crates) and CA certificates
|
||||
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/target/release/api .
|
||||
|
||||
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
ENV DATABASE_URL=sqlite:///app/data/template.db
|
||||
ENV SESSION_SECRET=supersecretchangeinproduction
|
||||
COPY --from=builder /build/target/release/server ./server
|
||||
COPY --from=builder /build/target/release/worker ./worker
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["./api"]
|
||||
ENV RUST_LOG=bootstrap=info,tower_http=info
|
||||
|
||||
CMD ["./server"]
|
||||
|
||||
56
Dockerfile.liquid
Normal file
56
Dockerfile.liquid
Normal file
@@ -0,0 +1,56 @@
|
||||
# ----- build -----
|
||||
FROM rust:slim-bookworm AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy manifests + lockfile first so cargo can fetch deps as a cached layer.
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/domain/Cargo.toml crates/domain/Cargo.toml
|
||||
COPY crates/application/Cargo.toml crates/application/Cargo.toml
|
||||
COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml
|
||||
{% if database == "sqlite" %}COPY crates/adapters/sqlite/Cargo.toml crates/adapters/sqlite/Cargo.toml
|
||||
{% endif %}{% if database == "postgres" %}COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Cargo.toml
|
||||
{% endif %}COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
|
||||
COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml
|
||||
COPY crates/bootstrap/Cargo.toml crates/bootstrap/Cargo.toml
|
||||
{% if worker %}COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
|
||||
{% endif %}
|
||||
# Stub every crate so cargo can resolve and fetch deps without real source
|
||||
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \
|
||||
xargs -I{} sh -c 'mkdir -p {}/src && echo "fn main(){}" > {}/src/main.rs && printf "" > {}/src/lib.rs'
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN cargo fetch
|
||||
|
||||
# For sqlx compile-time query verification run `cargo sqlx prepare` locally first,
|
||||
# then commit the .sqlx/ cache. Or pass DATABASE_URL as a build arg:
|
||||
# docker build --build-arg DATABASE_URL=<url> .
|
||||
ARG DATABASE_URL
|
||||
ENV SQLX_OFFLINE=${DATABASE_URL:+false}
|
||||
ENV SQLX_OFFLINE=${SQLX_OFFLINE:-true}
|
||||
|
||||
# Now copy real source — only invalidates cache on source changes
|
||||
COPY crates ./crates
|
||||
|
||||
RUN cargo build --release -p bootstrap{% if worker %} -p worker{% endif %}
|
||||
|
||||
# ----- runtime -----
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libssl3 wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /build/target/release/{{project_name}} ./server
|
||||
{% if worker %}COPY --from=builder /build/target/release/{{project_name}}-worker ./worker
|
||||
{% endif %}
|
||||
EXPOSE 3000
|
||||
|
||||
ENV RUST_LOG={{project_name}}=info,tower_http=info
|
||||
|
||||
CMD ["./server"]
|
||||
260
README.md
260
README.md
@@ -1,209 +1,137 @@
|
||||
# k-template
|
||||
|
||||
A production-ready, modular Rust API template for K-Suite applications, following Hexagonal Architecture principles.
|
||||
A cargo-generate template for personal Rust web services. Gives you auth, persistence, logging, CORS, and API docs out of the box so you can start writing domain code immediately.
|
||||
|
||||
## Features
|
||||
Follows the same hexagonal/ports-and-adapters architecture used in [thoughts](https://git.gabrielkaszewski.dev/GKaszewski/thoughts) and [movies-diary](https://git.gabrielkaszewski.dev/GKaszewski/movies-diary).
|
||||
|
||||
- **Hexagonal Architecture**: Clear separation between Domain, Infrastructure, and API layers
|
||||
- **JWT-Only Authentication**: Stateless Bearer token auth — no server-side sessions
|
||||
- **OIDC Integration**: Connect to any OpenID Connect provider (Keycloak, Auth0, Zitadel, etc.) with stateless cookie-based flow state
|
||||
- **Database Flexibility**: SQLite (default) or PostgreSQL via feature flags
|
||||
- **Type-Safe Domain**: Newtypes with built-in validation for emails, passwords, secrets, and OIDC values
|
||||
- **Cargo Generate Ready**: Pre-configured for scaffolding new services
|
||||
## What you get
|
||||
|
||||
## Quick Start
|
||||
- **Full hexagonal architecture** — `domain` → `application` → `adapters` → `presentation` → `bootstrap`, each as a separate crate with clear boundaries
|
||||
- **JWT auth wired end-to-end** — register, login, and `GET /auth/me` working from day one
|
||||
- **SQLite or PostgreSQL** — chosen at generation time, migrations included
|
||||
- **CORS + structured logging** — tower-http middleware configured in bootstrap
|
||||
- **Scalar API docs** at `/scalar`, OpenAPI JSON at `/api-docs/openapi.json`
|
||||
- **Optional worker binary** — tokio-based background job runner with an example job
|
||||
- **Optional OIDC stub** — placeholder adapter for OAuth2/OpenID Connect flows
|
||||
- **Docker-ready** — multi-stage Dockerfile with dependency layer caching, no live DB needed at build time
|
||||
|
||||
### Option 1: Use cargo-generate (Recommended)
|
||||
## Generate a new project
|
||||
|
||||
```bash
|
||||
cargo generate --git https://github.com/GKaszewski/k-template.git
|
||||
cargo generate --git https://git.gabrielkaszewski.dev/GKaszewski/k-template.git
|
||||
```
|
||||
|
||||
You'll be prompted to choose:
|
||||
- **Project name**: Your new service name
|
||||
- **Database**: `sqlite` or `postgres`
|
||||
- **JWT auth**: Enable Bearer token authentication
|
||||
- **OIDC**: Enable OpenID Connect integration
|
||||
You'll be prompted for:
|
||||
|
||||
### Option 2: Clone directly
|
||||
| Option | Choices | Default |
|
||||
|--------|---------|---------|
|
||||
| `project_name` | any snake_case string | — |
|
||||
| `database` | `sqlite`, `postgres` | `sqlite` |
|
||||
| `worker` | bool | false |
|
||||
| `auth_oidc` | bool | false |
|
||||
|
||||
## Generated project structure
|
||||
|
||||
```
|
||||
crates/
|
||||
domain/ pure Rust — entities, value objects, port traits, errors
|
||||
application/ use cases (RegisterUser, LoginUser, GetProfile) + test fakes
|
||||
api-types/ shared request/response DTOs with OpenAPI derives
|
||||
adapters/
|
||||
sqlite/ sqlx SQLite UserRepository + migrations
|
||||
postgres/ sqlx PostgreSQL UserRepository + migrations
|
||||
auth/ BcryptPasswordHasher, JwtTokenIssuer, OidcAdapter stub
|
||||
presentation/ axum handlers, JwtClaims extractor, routes, Scalar mount
|
||||
bootstrap/ Config from env, factory wiring, main entry point
|
||||
worker/ (optional) Job trait, JobRunner, ExampleJob, WorkerConfig
|
||||
```
|
||||
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
git clone https://github.com/GKaszewski/k-template.git my-api
|
||||
cd my-api
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
cargo run
|
||||
cargo run -p bootstrap
|
||||
```
|
||||
|
||||
The API will be available at `http://localhost:3000/api/v1/`.
|
||||
The server starts at `http://localhost:3000`.
|
||||
|
||||
## Configuration
|
||||
## Endpoints (out of the box)
|
||||
|
||||
All configuration is done via environment variables. See [.env.example](.env.example) for all options.
|
||||
|
||||
### Key Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `sqlite:data.db?mode=rwc` | Database connection string |
|
||||
| `COOKIE_SECRET` | *(insecure dev default)* | Secret for encrypting OIDC state cookie (≥64 bytes in production) |
|
||||
| `JWT_SECRET` | *(insecure dev default)* | Secret for signing JWT tokens (≥32 bytes in production) |
|
||||
| `JWT_EXPIRY_HOURS` | `24` | Token lifetime in hours |
|
||||
| `CORS_ALLOWED_ORIGINS` | `http://localhost:5173` | Comma-separated allowed origins |
|
||||
| `SECURE_COOKIE` | `false` | Set `true` when serving over HTTPS |
|
||||
| `PRODUCTION` | `false` | Enforces minimum secret lengths |
|
||||
|
||||
### OIDC Integration
|
||||
|
||||
To enable "Login with Google/Keycloak/etc.":
|
||||
|
||||
1. Enable the `auth-oidc` feature (on by default in cargo-generate)
|
||||
2. Set these environment variables:
|
||||
```env
|
||||
OIDC_ISSUER=https://your-provider.com
|
||||
OIDC_CLIENT_ID=your-client-id
|
||||
OIDC_CLIENT_SECRET=your-secret
|
||||
OIDC_REDIRECT_URL=http://localhost:3000/api/v1/auth/callback
|
||||
```
|
||||
3. Users start the flow at `GET /api/v1/auth/login/oidc`
|
||||
|
||||
OIDC state (CSRF token, PKCE verifier, nonce) is stored in a short-lived encrypted cookie — no database session table required.
|
||||
|
||||
## Feature Flags
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = ["sqlite", "auth-jwt"]
|
||||
```
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| `sqlite` | SQLite database (default) |
|
||||
| `postgres` | PostgreSQL database |
|
||||
| `auth-jwt` | JWT Bearer token authentication |
|
||||
| `auth-oidc` | OpenID Connect integration |
|
||||
|
||||
### Common Configurations
|
||||
|
||||
**JWT-only (minimal, default)**:
|
||||
```toml
|
||||
default = ["sqlite", "auth-jwt"]
|
||||
```
|
||||
|
||||
**OIDC + JWT (typical SPA backend)**:
|
||||
```toml
|
||||
default = ["sqlite", "auth-oidc", "auth-jwt"]
|
||||
```
|
||||
|
||||
**PostgreSQL + OIDC + JWT**:
|
||||
```toml
|
||||
default = ["postgres", "auth-oidc", "auth-jwt"]
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| `POST` | `/api/v1/auth/register` | — | Register with email + password → JWT |
|
||||
| `POST` | `/api/v1/auth/login` | — | Login with email + password → JWT |
|
||||
| `POST` | `/api/v1/auth/logout` | — | Returns 200; client drops the token |
|
||||
| `GET` | `/api/v1/auth/me` | Bearer | Current user info |
|
||||
| `POST` | `/api/v1/auth/token` | Bearer | Issue a fresh JWT (`auth-jwt`) |
|
||||
| `GET` | `/api/v1/auth/login/oidc` | — | Start OIDC flow, sets encrypted state cookie (`auth-oidc`) |
|
||||
| `GET` | `/api/v1/auth/callback` | — | Complete OIDC flow → JWT, clears cookie (`auth-oidc`) |
|
||||
|
||||
### Example: Register and use a token
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `POST` | `/api/v1/auth/register` | — | Create account → `AuthResponse` |
|
||||
| `POST` | `/api/v1/auth/login` | — | Login → `AuthResponse` |
|
||||
| `GET` | `/api/v1/auth/me` | Bearer | Current user profile |
|
||||
| `GET` | `/health` | — | `{"status":"ok"}` |
|
||||
| `GET` | `/scalar` | — | Interactive API docs |
|
||||
| `GET` | `/api-docs/openapi.json` | — | OpenAPI spec |
|
||||
|
||||
```bash
|
||||
# Register
|
||||
curl -X POST http://localhost:3000/api/v1/auth/register \
|
||||
curl -s -X POST http://localhost:3000/api/v1/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "user@example.com", "password": "mypassword"}'
|
||||
# → {"access_token": "eyJ...", "token_type": "Bearer", "expires_in": 86400}
|
||||
-d '{"email":"me@example.com","password":"password123"}' | jq
|
||||
|
||||
# Use the token
|
||||
curl http://localhost:3000/api/v1/auth/me \
|
||||
-H "Authorization: Bearer eyJ..."
|
||||
# Login and get token
|
||||
TOKEN=$(curl -s -X POST http://localhost:3000/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"me@example.com","password":"password123"}' | jq -r '.token')
|
||||
|
||||
# Profile
|
||||
curl -s http://localhost:3000/api/v1/auth/me \
|
||||
-H "Authorization: Bearer $TOKEN" | jq
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
## Configuration
|
||||
|
||||
```
|
||||
k-template/
|
||||
├── domain/ # Pure business logic — zero I/O dependencies
|
||||
│ └── src/
|
||||
│ ├── entities.rs # User entity
|
||||
│ ├── value_objects.rs # Email, Password, JwtSecret, OIDC newtypes
|
||||
│ ├── repositories.rs # Repository interfaces (ports)
|
||||
│ ├── services.rs # Domain services
|
||||
│ └── errors.rs # DomainError (Unauthenticated 401, Forbidden 403, …)
|
||||
│
|
||||
├── infra/ # Infrastructure adapters
|
||||
│ └── src/
|
||||
│ ├── auth/
|
||||
│ │ ├── jwt.rs # JwtValidator — create + verify tokens
|
||||
│ │ └── oidc.rs # OidcService + OidcState (cookie-serializable)
|
||||
│ ├── user_repository.rs # SQLite / PostgreSQL adapter
|
||||
│ ├── db.rs # DatabasePool re-export
|
||||
│ └── factory.rs # build_user_repository()
|
||||
│
|
||||
├── api/ # HTTP layer
|
||||
│ └── src/
|
||||
│ ├── routes/
|
||||
│ │ ├── auth.rs # Login, register, logout, me, OIDC flow
|
||||
│ │ └── config.rs # /config endpoint
|
||||
│ ├── config.rs # Config::from_env()
|
||||
│ ├── state.rs # AppState (user_service, cookie_key, jwt_validator, …)
|
||||
│ ├── extractors.rs # CurrentUser (JWT Bearer extractor)
|
||||
│ ├── error.rs # ApiError → HTTP status mapping
|
||||
│ └── dto.rs # LoginRequest, RegisterRequest, TokenResponse, …
|
||||
│
|
||||
├── migrations_sqlite/
|
||||
├── migrations_postgres/
|
||||
├── .env.example
|
||||
└── compose.yml # Docker Compose for local dev
|
||||
```
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | `sqlite://data.db` | Database connection string |
|
||||
| `JWT_SECRET` | *(required)* | Signing secret — min 32 chars in production |
|
||||
| `HOST` | `0.0.0.0` | Bind address |
|
||||
| `PORT` | `3000` | Listen port |
|
||||
| `CORS_ALLOWED_ORIGINS` | `http://localhost:3000` | Comma-separated allowed origins |
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
cargo test
|
||||
|
||||
# Domain only
|
||||
cargo test -p domain
|
||||
|
||||
# Infra only (SQLite integration tests)
|
||||
cargo test -p infra
|
||||
# Unit tests (no DB required)
|
||||
cargo test -p domain -p application -p adapters-auth
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
13 unit tests cover email validation, use case logic (register/login/get_profile), bcrypt roundtrip, and JWT encode/verify.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# SQLite
|
||||
sqlx migrate run --source migrations_sqlite
|
||||
# Build
|
||||
docker build -t my-app .
|
||||
|
||||
# PostgreSQL
|
||||
sqlx migrate run --source migrations_postgres
|
||||
# Run
|
||||
docker run -p 3000:3000 \
|
||||
-e DATABASE_URL=sqlite:///data/app.db \
|
||||
-e JWT_SECRET=change-me-32-chars-minimum-here \
|
||||
my-app
|
||||
```
|
||||
|
||||
### Building with specific features
|
||||
Or with compose:
|
||||
|
||||
```bash
|
||||
# Minimal: SQLite + JWT only
|
||||
cargo build -F sqlite,auth-jwt
|
||||
|
||||
# Full: SQLite + JWT + OIDC
|
||||
cargo build -F sqlite,auth-jwt,auth-oidc
|
||||
|
||||
# PostgreSQL variant
|
||||
cargo build --no-default-features -F postgres,auth-jwt,auth-oidc
|
||||
docker compose up
|
||||
```
|
||||
|
||||
The Dockerfile uses dependency layer caching (manifests copied and fetched before source) so rebuilds after source-only changes are fast. No live database is needed at compile time — the `.sqlx` offline cache is committed.
|
||||
|
||||
## What to do after generating
|
||||
|
||||
1. Add your domain entities and value objects to `crates/domain/`
|
||||
2. Write use cases in `crates/application/`
|
||||
3. Add DB columns/tables via new migration files in `crates/adapters/sqlite/migrations/`
|
||||
4. Add handlers in `crates/presentation/src/handlers/`
|
||||
5. Wire new use cases in `crates/bootstrap/src/factory.rs`
|
||||
|
||||
Auth, CORS, logging, and docs are already done — focus on what makes your project unique.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -1,33 +1,59 @@
|
||||
[template]
|
||||
cargo_generate_version = ">=0.21.0"
|
||||
ignore = [".git", "target", ".idea", ".vscode", "data.db"]
|
||||
|
||||
[filenames]
|
||||
"api/Cargo.toml.template" = "api/Cargo.toml"
|
||||
"infra/Cargo.toml.template" = "infra/Cargo.toml"
|
||||
ignore = [".git", "target", ".idea", ".vscode", "data.db", "*.liquid", "**/.sqlx", "**/dev.db", "Dockerfile", "compose.yml"]
|
||||
|
||||
[placeholders.project_name]
|
||||
type = "string"
|
||||
prompt = "Project name"
|
||||
prompt = "Project name (snake_case)"
|
||||
|
||||
[placeholders.database]
|
||||
type = "string"
|
||||
prompt = "Database type"
|
||||
prompt = "Database backend"
|
||||
choices = ["sqlite", "postgres"]
|
||||
default = "sqlite"
|
||||
|
||||
[placeholders.auth_jwt]
|
||||
[placeholders.worker]
|
||||
type = "bool"
|
||||
prompt = "Enable JWT authentication (Bearer tokens)?"
|
||||
default = true
|
||||
prompt = "Include background worker binary?"
|
||||
default = false
|
||||
|
||||
[placeholders.auth_oidc]
|
||||
type = "bool"
|
||||
prompt = "Enable OIDC integration (Login with Google, etc.)?"
|
||||
default = true
|
||||
prompt = "Include OIDC/OAuth2 adapter stub?"
|
||||
default = false
|
||||
|
||||
[conditional.'database == "sqlite"']
|
||||
ignore = ["migrations_postgres"]
|
||||
ignore = ["crates/adapters/postgres"]
|
||||
|
||||
[conditional.'database == "postgres"']
|
||||
ignore = ["migrations_sqlite"]
|
||||
ignore = ["crates/adapters/sqlite"]
|
||||
|
||||
[conditional.'!worker']
|
||||
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",
|
||||
]
|
||||
|
||||
93
compose.yml
93
compose.yml
@@ -1,89 +1,34 @@
|
||||
services:
|
||||
backend:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- SESSION_SECRET=dev_secret_key_12345
|
||||
- DATABASE_URL=sqlite:///app/data/notes.db
|
||||
- CORS_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5173
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- DB_MAX_CONNECTIONS=5
|
||||
- DB_MIN_CONNECTIONS=1
|
||||
- SECURE_COOKIE=true
|
||||
DATABASE_URL: sqlite:///data/app.db
|
||||
JWT_SECRET: change-me-in-production-min-32-chars
|
||||
HOST: 0.0.0.0
|
||||
PORT: "3000"
|
||||
RUST_LOG: bootstrap=info,tower_http=info
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
|
||||
# nats:
|
||||
# image: nats:alpine
|
||||
# ports:
|
||||
# - "4222:4222"
|
||||
# - "6222:6222"
|
||||
# - "8222:8222"
|
||||
# restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: k_template_db
|
||||
ports:
|
||||
- "5439:5432"
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
|
||||
zitadel-db:
|
||||
image: postgres:16-alpine
|
||||
container_name: zitadel_db
|
||||
environment:
|
||||
POSTGRES_USER: zitadel
|
||||
POSTGRES_PASSWORD: zitadel_password
|
||||
POSTGRES_DB: zitadel
|
||||
- db_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U zitadel -d zitadel"]
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
- zitadel_db_data:/var/lib/postgresql/data
|
||||
start_period: 10s
|
||||
|
||||
zitadel:
|
||||
image: ghcr.io/zitadel/zitadel:latest
|
||||
container_name: zitadel_local
|
||||
depends_on:
|
||||
zitadel-db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8086:8080"
|
||||
# USE start-from-init (Fixes the "relation does not exist" bug)
|
||||
command: 'start-from-init --masterkey "MasterkeyNeedsToBeExactly32Bytes"'
|
||||
worker:
|
||||
build: .
|
||||
entrypoint: ["./worker"]
|
||||
environment:
|
||||
# Database Connection
|
||||
ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db
|
||||
ZITADEL_DATABASE_POSTGRES_PORT: 5432
|
||||
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
|
||||
|
||||
# APPLICATION USER (Zitadel uses this to run)
|
||||
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
|
||||
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_password
|
||||
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
|
||||
|
||||
# ADMIN USER (Zitadel uses this to create tables/migrations)
|
||||
# We use 'zitadel' because it is the owner of the DB in your postgres container.
|
||||
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: zitadel
|
||||
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: zitadel_password
|
||||
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
|
||||
|
||||
# General Config
|
||||
ZITADEL_EXTERNALDOMAIN: localhost
|
||||
ZITADEL_EXTERNALPORT: 8086
|
||||
ZITADEL_EXTERNALSECURE: "false"
|
||||
ZITADEL_TLS_ENABLED: "false"
|
||||
|
||||
ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "false"
|
||||
DATABASE_URL: sqlite:///data/app.db
|
||||
RUST_LOG: worker=info
|
||||
volumes:
|
||||
- db_data:/data
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
zitadel_db_data:
|
||||
57
compose.yml.liquid
Normal file
57
compose.yml.liquid
Normal file
@@ -0,0 +1,57 @@
|
||||
services:
|
||||
{% if database == "postgres" %} postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: {{project_name}}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
{% endif %} app:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
{% if database == "sqlite" %} DATABASE_URL: sqlite:///data/app.db
|
||||
{% endif %}{% if database == "postgres" %} DATABASE_URL: postgres://postgres:postgres@postgres:5432/{{project_name}}
|
||||
{% endif %} JWT_SECRET: change-me-in-production-min-32-chars
|
||||
HOST: 0.0.0.0
|
||||
PORT: "3000"
|
||||
RUST_LOG: {{project_name}}=info,tower_http=info
|
||||
{% if database == "sqlite" %} volumes:
|
||||
- db_data:/data
|
||||
{% endif %}{% if database == "postgres" %} depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
{% endif %} healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/health || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
{% if worker %}
|
||||
worker:
|
||||
build: .
|
||||
entrypoint: ["./worker"]
|
||||
environment:
|
||||
{% if database == "sqlite" %} DATABASE_URL: sqlite:///data/app.db
|
||||
{% endif %}{% if database == "postgres" %} DATABASE_URL: postgres://postgres:postgres@postgres:5432/{{project_name}}
|
||||
{% endif %} RUST_LOG: worker=info
|
||||
{% if database == "sqlite" %} volumes:
|
||||
- db_data:/data
|
||||
{% endif %}{% if database == "postgres" %} depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
{% endif %}
|
||||
{% endif %}volumes:
|
||||
{% if database == "sqlite" %} db_data:
|
||||
{% endif %}{% if database == "postgres" %} postgres_data:
|
||||
{% endif %}
|
||||
19
crates/adapters/auth/Cargo.toml.liquid
Normal file
19
crates/adapters/auth/Cargo.toml.liquid
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "adapters-auth"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
bcrypt = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
{% if auth_oidc %}
|
||||
openidconnect = "3"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
{% endif %}
|
||||
7
crates/adapters/auth/src/lib.rs.liquid
Normal file
7
crates/adapters/auth/src/lib.rs.liquid
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod jwt;
|
||||
{% if auth_oidc %}pub mod oidc;{% endif %}
|
||||
pub mod password;
|
||||
|
||||
pub use jwt::JwtTokenIssuer;
|
||||
{% if auth_oidc %}pub use oidc::OidcAdapter;{% endif %}
|
||||
pub use password::BcryptPasswordHasher;
|
||||
21
crates/adapters/storage/Cargo.toml
Normal file
21
crates/adapters/storage/Cargo.toml
Normal file
@@ -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 }
|
||||
310
crates/adapters/storage/src/adapter.rs
Normal file
310
crates/adapters/storage/src/adapter.rs
Normal file
@@ -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<dyn ObjectStore>,
|
||||
prefix: String,
|
||||
}
|
||||
|
||||
impl ObjectStorageAdapter {
|
||||
pub fn new(store: Arc<dyn ObjectStore>, prefix: impl Into<String>) -> Result<Self, DomainError> {
|
||||
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<DataStream, DomainError> {
|
||||
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<Vec<String>, 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());
|
||||
}
|
||||
}
|
||||
90
crates/adapters/storage/src/config.rs
Normal file
90
crates/adapters/storage/src/config.rs
Normal file
@@ -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<String>,
|
||||
// s3/minio backend:
|
||||
pub s3_endpoint: Option<String>,
|
||||
pub s3_access_key_id: Option<String>,
|
||||
pub s3_secret_access_key: Option<String>,
|
||||
pub s3_bucket: Option<String>,
|
||||
pub s3_region: Option<String>,
|
||||
// gcs backend:
|
||||
pub gcs_bucket: Option<String>,
|
||||
}
|
||||
|
||||
impl StorageConfig {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
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<Arc<dyn ObjectStore>> {
|
||||
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 { "" },
|
||||
),
|
||||
}
|
||||
}
|
||||
5
crates/adapters/storage/src/lib.rs
Normal file
5
crates/adapters/storage/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod adapter;
|
||||
pub mod config;
|
||||
|
||||
pub use adapter::ObjectStorageAdapter;
|
||||
pub use config::{build_store, StorageConfig};
|
||||
@@ -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 }
|
||||
|
||||
36
crates/bootstrap/Cargo.toml.liquid
Normal file
36
crates/bootstrap/Cargo.toml.liquid
Normal file
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "bootstrap"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "{{project_name}}"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
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" }
|
||||
{% endif %}
|
||||
{% if database == "postgres" %}
|
||||
adapters-postgres = { path = "../adapters/postgres" }
|
||||
{% endif %}
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
28
crates/bootstrap/src/config.rs.liquid
Normal file
28
crates/bootstrap/src/config.rs.liquid
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Router> {
|
||||
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(
|
||||
|
||||
62
crates/bootstrap/src/factory.rs.liquid
Normal file
62
crates/bootstrap/src/factory.rs.liquid
Normal file
@@ -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<Router> {
|
||||
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::<HeaderValue>().ok())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
Ok(app_router()
|
||||
.with_state(state)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(cors))
|
||||
}
|
||||
@@ -9,3 +9,5 @@ chrono = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
@@ -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;
|
||||
|
||||
7
crates/domain/src/ports/mod.rs.liquid
Normal file
7
crates/domain/src/ports/mod.rs.liquid
Normal file
@@ -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;
|
||||
52
crates/domain/src/ports/storage.rs
Normal file
52
crates/domain/src/ports/storage.rs
Normal file
@@ -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<Bytes, DomainError>>;
|
||||
|
||||
/// 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<DataStream, DomainError>;
|
||||
|
||||
/// 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<Vec<String>, DomainError>;
|
||||
|
||||
/// Convenience: reads the entire content of `key` into memory. Wraps `get`.
|
||||
async fn get_bytes(&self, key: &str) -> Result<Bytes, DomainError> {
|
||||
let mut stream = self.get(key).await?;
|
||||
let mut buf: Vec<u8> = 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<dyn StoragePort>` is the intended DI type everywhere.
|
||||
/// `StorageReader` and `StorageWriter` exist for implementation clarity, but Rust does not
|
||||
/// support narrowing `Arc<dyn StoragePort>` to `Arc<dyn StorageReader>` at runtime.
|
||||
/// Inject `Arc<dyn StoragePort>` into constructors and pass `.clone()` from the factory.
|
||||
pub trait StoragePort: StorageReader + StorageWriter {}
|
||||
impl<T: StorageReader + StorageWriter> StoragePort for T {}
|
||||
27
crates/presentation/src/handlers/storage_example.rs
Normal file
27
crates/presentation/src/handlers/storage_example.rs
Normal file
@@ -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<String>,
|
||||
// State(state): State<AppState>,
|
||||
// ) -> Result<impl IntoResponse, StatusCode> {
|
||||
// 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)
|
||||
// }
|
||||
@@ -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<LoginUser>,
|
||||
pub get_profile_uc: Arc<GetProfile>,
|
||||
pub token_issuer: Arc<dyn TokenIssuer>,
|
||||
/// Direct storage access for handlers. Use cases that need storage should receive
|
||||
/// `Arc<dyn StoragePort>` in their own constructor rather than reading it from `AppState`.
|
||||
pub storage: Arc<dyn StoragePort>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -16,7 +19,8 @@ impl AppState {
|
||||
login_uc: Arc<LoginUser>,
|
||||
get_profile_uc: Arc<GetProfile>,
|
||||
token_issuer: Arc<dyn TokenIssuer>,
|
||||
storage: Arc<dyn StoragePort>,
|
||||
) -> Self {
|
||||
Self { register_uc, login_uc, get_profile_uc, token_issuer }
|
||||
Self { register_uc, login_uc, get_profile_uc, token_issuer, storage }
|
||||
}
|
||||
}
|
||||
|
||||
28
crates/presentation/src/state.rs.liquid
Normal file
28
crates/presentation/src/state.rs.liquid
Normal file
@@ -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<RegisterUser>,
|
||||
pub login_uc: Arc<LoginUser>,
|
||||
pub get_profile_uc: Arc<GetProfile>,
|
||||
pub token_issuer: Arc<dyn TokenIssuer>,
|
||||
{% if storage %}pub storage: Arc<dyn StoragePort>,{% endif %}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(
|
||||
register_uc: Arc<RegisterUser>,
|
||||
login_uc: Arc<LoginUser>,
|
||||
get_profile_uc: Arc<GetProfile>,
|
||||
token_issuer: Arc<dyn TokenIssuer>,
|
||||
{% if storage %}storage: Arc<dyn StoragePort>,{% endif %}
|
||||
) -> Self {
|
||||
Self { register_uc, login_uc, get_profile_uc, token_issuer{% if storage %}, storage{% endif %} }
|
||||
}
|
||||
}
|
||||
18
crates/worker/Cargo.toml
Normal file
18
crates/worker/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "worker"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "worker"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
adapters-sqlite = { path = "../adapters/sqlite" }
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
23
crates/worker/Cargo.toml.liquid
Normal file
23
crates/worker/Cargo.toml.liquid
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "worker"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "{{project_name}}-worker"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
{% if database == "sqlite" %}
|
||||
adapters-sqlite = { path = "../adapters/sqlite" }
|
||||
{% endif %}
|
||||
{% if database == "postgres" %}
|
||||
adapters-postgres = { path = "../adapters/postgres" }
|
||||
{% endif %}
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
18
crates/worker/src/config.rs
Normal file
18
crates/worker/src/config.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkerConfig {
|
||||
pub database_url: String,
|
||||
pub example_job_interval_secs: u64,
|
||||
}
|
||||
|
||||
impl WorkerConfig {
|
||||
pub fn from_env() -> Self {
|
||||
dotenvy::dotenv().ok();
|
||||
Self {
|
||||
database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"),
|
||||
example_job_interval_secs: std::env::var("EXAMPLE_JOB_INTERVAL_SECS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60),
|
||||
}
|
||||
}
|
||||
}
|
||||
7
crates/worker/src/job.rs
Normal file
7
crates/worker/src/job.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[async_trait]
|
||||
pub trait Job: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
async fn run(&self) -> anyhow::Result<()>;
|
||||
}
|
||||
14
crates/worker/src/jobs/example.rs
Normal file
14
crates/worker/src/jobs/example.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use async_trait::async_trait;
|
||||
use tracing::info;
|
||||
use crate::job::Job;
|
||||
|
||||
pub struct ExampleJob;
|
||||
|
||||
#[async_trait]
|
||||
impl Job for ExampleJob {
|
||||
fn name(&self) -> &str { "example" }
|
||||
async fn run(&self) -> anyhow::Result<()> {
|
||||
info!("example job ran — replace with real work");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
2
crates/worker/src/jobs/mod.rs
Normal file
2
crates/worker/src/jobs/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod example;
|
||||
pub use example::ExampleJob;
|
||||
34
crates/worker/src/main.rs
Normal file
34
crates/worker/src/main.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
|
||||
mod config;
|
||||
mod job;
|
||||
mod jobs;
|
||||
mod runner;
|
||||
|
||||
use jobs::ExampleJob;
|
||||
use runner::JobRunner;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::from_default_env()
|
||||
.add_directive("worker=info".parse()?),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = config::WorkerConfig::from_env();
|
||||
info!("Worker starting");
|
||||
|
||||
let _pool = adapters_sqlite::connect(&config.database_url).await?;
|
||||
adapters_sqlite::run_migrations(&_pool).await?;
|
||||
|
||||
let interval = Duration::from_secs(config.example_job_interval_secs);
|
||||
let runner = JobRunner::new().register(Arc::new(ExampleJob), interval);
|
||||
|
||||
info!("Worker running");
|
||||
runner.run().await;
|
||||
Ok(())
|
||||
}
|
||||
34
crates/worker/src/runner.rs
Normal file
34
crates/worker/src/runner.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::{error, info};
|
||||
use crate::job::Job;
|
||||
|
||||
pub struct JobRunner {
|
||||
jobs: Vec<(Arc<dyn Job>, Duration)>,
|
||||
}
|
||||
|
||||
impl JobRunner {
|
||||
pub fn new() -> Self { Self { jobs: vec![] } }
|
||||
|
||||
pub fn register(mut self, job: Arc<dyn Job>, interval: Duration) -> Self {
|
||||
self.jobs.push((job, interval));
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn run(self) {
|
||||
let handles: Vec<_> = self.jobs.into_iter().map(|(job, interval)| {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
info!(job = job.name(), "running job");
|
||||
if let Err(e) = job.run().await {
|
||||
error!(job = job.name(), error = %e, "job failed");
|
||||
}
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
})
|
||||
}).collect();
|
||||
for handle in handles { let _ = handle.await; }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for JobRunner { fn default() -> Self { Self::new() } }
|
||||
@@ -1,11 +0,0 @@
|
||||
-- Create users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
password_hash TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_subject ON users(subject);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
@@ -1,10 +0,0 @@
|
||||
-- Create users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
Reference in New Issue
Block a user