From f9cb142c3b0e938c610d54d9463c29842c679926 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 31 May 2026 03:08:38 +0200 Subject: [PATCH] init: scaffold from k-template with postgres + worker --- .env.example | 65 + .gitignore | 10 + Cargo.lock | 3147 +++++++++++++++++ Cargo.toml | 42 + Makefile | 28 + README.md | 137 + crates/adapters/auth/Cargo.toml | 16 + crates/adapters/auth/src/jwt.rs | 74 + crates/adapters/auth/src/lib.rs | 7 + crates/adapters/auth/src/password.rs | 38 + crates/adapters/postgres/Cargo.toml | 12 + .../postgres/migrations/001_init_users.sql | 7 + crates/adapters/postgres/src/db.rs | 14 + crates/adapters/postgres/src/lib.rs | 5 + .../adapters/postgres/src/user_repository.rs | 86 + 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/api-types/Cargo.toml | 11 + crates/api-types/src/lib.rs | 2 + crates/api-types/src/requests.rs | 11 + crates/api-types/src/responses.rs | 27 + crates/application/Cargo.toml | 12 + crates/application/src/lib.rs | 2 + crates/application/src/testing.rs | 79 + .../application/src/use_cases/get_profile.rs | 40 + crates/application/src/use_cases/login.rs | 74 + crates/application/src/use_cases/mod.rs | 7 + crates/application/src/use_cases/register.rs | 72 + crates/bootstrap/Cargo.toml | 28 + crates/bootstrap/src/config.rs | 28 + crates/bootstrap/src/factory.rs | 58 + crates/bootstrap/src/lib.rs | 0 crates/bootstrap/src/main.rs | 28 + crates/domain/Cargo.toml | 13 + crates/domain/src/entities/mod.rs | 2 + crates/domain/src/entities/user.rs | 17 + crates/domain/src/errors.rs | 13 + crates/domain/src/events.rs | 7 + crates/domain/src/lib.rs | 5 + crates/domain/src/ports/auth.rs | 14 + crates/domain/src/ports/mod.rs | 7 + crates/domain/src/ports/storage.rs | 52 + crates/domain/src/ports/user_repo.rs | 10 + crates/domain/src/value_objects/email.rs | 42 + crates/domain/src/value_objects/mod.rs | 9 + crates/domain/src/value_objects/password.rs | 14 + crates/domain/src/value_objects/role.rs | 23 + crates/domain/src/value_objects/user_id.rs | 22 + crates/presentation/Cargo.toml | 19 + crates/presentation/src/errors.rs | 25 + crates/presentation/src/extractors/auth.rs | 38 + crates/presentation/src/extractors/json.rs | 28 + crates/presentation/src/extractors/mod.rs | 5 + crates/presentation/src/handlers/auth.rs | 56 + crates/presentation/src/handlers/health.rs | 7 + crates/presentation/src/handlers/mod.rs | 2 + .../src/handlers/storage_example.rs | 27 + crates/presentation/src/lib.rs | 6 + crates/presentation/src/openapi/mod.rs | 41 + crates/presentation/src/routes.rs | 16 + crates/presentation/src/state.rs | 26 + crates/worker/Cargo.toml | 21 + crates/worker/src/config.rs | 18 + crates/worker/src/job.rs | 7 + crates/worker/src/jobs/example.rs | 14 + crates/worker/src/jobs/mod.rs | 2 + crates/worker/src/main.rs | 34 + crates/worker/src/runner.rs | 34 + 70 files changed, 5269 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Makefile create mode 100644 README.md create mode 100644 crates/adapters/auth/Cargo.toml create mode 100644 crates/adapters/auth/src/jwt.rs create mode 100644 crates/adapters/auth/src/lib.rs create mode 100644 crates/adapters/auth/src/password.rs create mode 100644 crates/adapters/postgres/Cargo.toml create mode 100644 crates/adapters/postgres/migrations/001_init_users.sql create mode 100644 crates/adapters/postgres/src/db.rs create mode 100644 crates/adapters/postgres/src/lib.rs create mode 100644 crates/adapters/postgres/src/user_repository.rs 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/api-types/Cargo.toml create mode 100644 crates/api-types/src/lib.rs create mode 100644 crates/api-types/src/requests.rs create mode 100644 crates/api-types/src/responses.rs create mode 100644 crates/application/Cargo.toml create mode 100644 crates/application/src/lib.rs create mode 100644 crates/application/src/testing.rs create mode 100644 crates/application/src/use_cases/get_profile.rs create mode 100644 crates/application/src/use_cases/login.rs create mode 100644 crates/application/src/use_cases/mod.rs create mode 100644 crates/application/src/use_cases/register.rs create mode 100644 crates/bootstrap/Cargo.toml create mode 100644 crates/bootstrap/src/config.rs create mode 100644 crates/bootstrap/src/factory.rs create mode 100644 crates/bootstrap/src/lib.rs create mode 100644 crates/bootstrap/src/main.rs create mode 100644 crates/domain/Cargo.toml create mode 100644 crates/domain/src/entities/mod.rs create mode 100644 crates/domain/src/entities/user.rs create mode 100644 crates/domain/src/errors.rs create mode 100644 crates/domain/src/events.rs create mode 100644 crates/domain/src/lib.rs create mode 100644 crates/domain/src/ports/auth.rs create mode 100644 crates/domain/src/ports/mod.rs create mode 100644 crates/domain/src/ports/storage.rs create mode 100644 crates/domain/src/ports/user_repo.rs create mode 100644 crates/domain/src/value_objects/email.rs create mode 100644 crates/domain/src/value_objects/mod.rs create mode 100644 crates/domain/src/value_objects/password.rs create mode 100644 crates/domain/src/value_objects/role.rs create mode 100644 crates/domain/src/value_objects/user_id.rs create mode 100644 crates/presentation/Cargo.toml create mode 100644 crates/presentation/src/errors.rs create mode 100644 crates/presentation/src/extractors/auth.rs create mode 100644 crates/presentation/src/extractors/json.rs create mode 100644 crates/presentation/src/extractors/mod.rs create mode 100644 crates/presentation/src/handlers/auth.rs create mode 100644 crates/presentation/src/handlers/health.rs create mode 100644 crates/presentation/src/handlers/mod.rs create mode 100644 crates/presentation/src/handlers/storage_example.rs create mode 100644 crates/presentation/src/lib.rs create mode 100644 crates/presentation/src/openapi/mod.rs create mode 100644 crates/presentation/src/routes.rs create mode 100644 crates/presentation/src/state.rs create mode 100644 crates/worker/Cargo.toml create mode 100644 crates/worker/src/config.rs create mode 100644 crates/worker/src/job.rs create mode 100644 crates/worker/src/jobs/example.rs create mode 100644 crates/worker/src/jobs/mod.rs create mode 100644 crates/worker/src/main.rs create mode 100644 crates/worker/src/runner.rs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a01c603 --- /dev/null +++ b/.env.example @@ -0,0 +1,65 @@ +# ============================================================================ +# K-Template Configuration +# ============================================================================ +# Copy this file to .env and adjust values for your environment. + +# ============================================================================ +# Server +# ============================================================================ +HOST=127.0.0.1 +PORT=3000 + +# ============================================================================ +# Database +# ============================================================================ +# SQLite (default) +DATABASE_URL=sqlite:data.db?mode=rwc + +# PostgreSQL (requires postgres feature flag) +# DATABASE_URL=postgres://user:password@localhost:5432/mydb + +DB_MAX_CONNECTIONS=5 +DB_MIN_CONNECTIONS=1 + +# ============================================================================ +# Cookie Secret +# ============================================================================ +# Used to encrypt the OIDC state cookie (CSRF token, PKCE verifier, nonce). +# Must be at least 64 characters in production. +COOKIE_SECRET=your-cookie-secret-key-must-be-at-least-64-characters-long-for-security!! + +# Set to true when serving over HTTPS +SECURE_COOKIE=false + +# ============================================================================ +# JWT +# ============================================================================ +# Must be at least 32 characters in production. +JWT_SECRET=your-jwt-secret-key-at-least-32-chars + +# Optional: embed issuer/audience claims in tokens +# JWT_ISSUER=your-app-name +# JWT_AUDIENCE=your-app-audience + +# Token lifetime in hours (default: 24) +JWT_EXPIRY_HOURS=24 + +# ============================================================================ +# OIDC (optional — requires auth-oidc feature flag) +# ============================================================================ +# OIDC_ISSUER=https://your-oidc-provider.com +# OIDC_CLIENT_ID=your-client-id +# OIDC_CLIENT_SECRET=your-client-secret +# OIDC_REDIRECT_URL=http://localhost:3000/api/v1/auth/callback +# OIDC_RESOURCE_ID=your-resource-id # optional audience claim to verify + +# ============================================================================ +# CORS +# ============================================================================ +CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 + +# ============================================================================ +# Production Mode +# ============================================================================ +# Set to true/production/1 to enforce minimum secret lengths and other checks. +PRODUCTION=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6336341 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/target +**/*.rs.bk +.env +data.db +*.db-shm +*.db-wal +.idea/ +.vscode/ +**/dev.db +docs/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c05915b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3147 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adapters-auth" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "bcrypt", + "chrono", + "domain", + "jsonwebtoken", + "serde", + "tokio", + "uuid", +] + +[[package]] +name = "adapters-postgres" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "domain", + "sqlx", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "api-types" +version = "0.1.0" +dependencies = [ + "chrono", + "domain", + "serde", + "utoipa", + "uuid", +] + +[[package]] +name = "application" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "domain", + "thiserror", + "tokio", + "uuid", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" +dependencies = [ + "base64", + "blowfish", + "getrandom 0.2.17", + "subtle", + "zeroize", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "bootstrap" +version = "0.1.0" +dependencies = [ + "adapters-auth", + "adapters-postgres", + "adapters-storage", + "anyhow", + "application", + "axum", + "domain", + "dotenvy", + "presentation", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "domain" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "serde", + "thiserror", + "uuid", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +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", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +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]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "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]] +name = "hyper-util" +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]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presentation" +version = "0.1.0" +dependencies = [ + "api-types", + "application", + "async-trait", + "axum", + "chrono", + "domain", + "serde", + "serde_json", + "tower-http", + "tracing", + "utoipa", + "utoipa-scalar", + "uuid", +] + +[[package]] +name = "proc-macro2" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "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]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "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]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +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" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", + "uuid", +] + +[[package]] +name = "utoipa-scalar" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719" +dependencies = [ + "axum", + "serde", + "serde_json", + "utoipa", +] + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "worker" +version = "0.1.0" +dependencies = [ + "adapters-postgres", + "anyhow", + "async-trait", + "domain", + "dotenvy", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f231fc8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,42 @@ +[workspace] +members = [ + "crates/domain", + "crates/application", + "crates/api-types", + + "crates/adapters/postgres", + "crates/adapters/auth", + "crates/adapters/storage", + "crates/presentation", + "crates/bootstrap", + "crates/worker", +] +resolver = "2" + +[workspace.dependencies] +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"] } +serde_json = "1.0" +uuid = { version = "1.0", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +dotenvy = "0.15" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +axum = { version = "0.8", features = ["macros"] } +tower-http = { version = "0.6", features = ["cors", "trace"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "uuid", "chrono", "macros"] } +jsonwebtoken = "9.3" +bcrypt = "0.15" +utoipa = { version = "5.3", features = ["axum_extras", "uuid", "chrono"] } +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-storage = { path = "crates/adapters/storage" } +presentation = { path = "crates/presentation" } diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e2f26a7 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +.DEFAULT_GOAL := check + +# Run the full local check suite — same order as CI would. +check: fmt-check clippy test + @echo "✅ All checks passed" + +# Apply rustfmt to all files. +fmt: + cargo fmt + +# Check formatting without modifying files (CI-safe). +fmt-check: + cargo fmt --check + +# Run Clippy and treat warnings as errors. +clippy: + cargo clippy -- -D warnings + +# Run the test suite. +test: + cargo test + +# Apply fmt + clippy auto-fixes in one shot. +fix: + cargo fmt + cargo clippy --fix --allow-dirty --allow-staged + +.PHONY: check fmt fmt-check clippy test fix diff --git a/README.md b/README.md new file mode 100644 index 0000000..88155bf --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# k-template + +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. + +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). + +## What you get + +- **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 + +## Generate a new project + +```bash +cargo generate --git https://git.gabrielkaszewski.dev/GKaszewski/k-template.git +``` + +You'll be prompted for: + +| 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 +cp .env.example .env +cargo run -p bootstrap +``` + +The server starts at `http://localhost:3000`. + +## Endpoints (out of the box) + +| 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 -s -X POST http://localhost:3000/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"me@example.com","password":"password123"}' | jq + +# 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 +``` + +## Configuration + +| 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 | + +## Tests + +```bash +# Unit tests (no DB required) +cargo test -p domain -p application -p adapters-auth +``` + +13 unit tests cover email validation, use case logic (register/login/get_profile), bcrypt roundtrip, and JWT encode/verify. + +## Docker + +```bash +# Build +docker build -t my-app . + +# Run +docker run -p 3000:3000 \ + -e DATABASE_URL=sqlite:///data/app.db \ + -e JWT_SECRET=change-me-32-chars-minimum-here \ + my-app +``` + +Or with compose: + +```bash +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 diff --git a/crates/adapters/auth/Cargo.toml b/crates/adapters/auth/Cargo.toml new file mode 100644 index 0000000..0fb6347 --- /dev/null +++ b/crates/adapters/auth/Cargo.toml @@ -0,0 +1,16 @@ +[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 } + diff --git a/crates/adapters/auth/src/jwt.rs b/crates/adapters/auth/src/jwt.rs new file mode 100644 index 0000000..aaff31c --- /dev/null +++ b/crates/adapters/auth/src/jwt.rs @@ -0,0 +1,74 @@ +use async_trait::async_trait; +use chrono::Utc; +use domain::{errors::DomainError, ports::TokenIssuer, value_objects::{Role, UserId}}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub sub: String, + pub role: String, + pub exp: i64, +} + +pub struct JwtTokenIssuer { + encoding_key: EncodingKey, + decoding_key: DecodingKey, + expiry_hours: i64, +} + +impl JwtTokenIssuer { + pub fn new(secret: &str) -> Self { + Self { + encoding_key: EncodingKey::from_secret(secret.as_bytes()), + decoding_key: DecodingKey::from_secret(secret.as_bytes()), + expiry_hours: 24, + } + } +} + +#[async_trait] +impl TokenIssuer for JwtTokenIssuer { + async fn issue(&self, user_id: &UserId, role: &Role) -> Result { + let claims = Claims { + sub: user_id.to_string(), + role: role.to_string(), + exp: (Utc::now() + chrono::Duration::hours(self.expiry_hours)).timestamp(), + }; + encode(&Header::default(), &claims, &self.encoding_key) + .map_err(|e| DomainError::Internal(e.to_string())) + } + + async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError> { + let data = decode::(token, &self.decoding_key, &Validation::default()) + .map_err(|_| DomainError::Unauthorized("Invalid or expired token".to_string()))?; + let uuid = uuid::Uuid::parse_str(&data.claims.sub) + .map_err(|_| DomainError::Unauthorized("Invalid token subject".to_string()))?; + let role = Role::from_str(&data.claims.role) + .map_err(|_| DomainError::Unauthorized("Invalid role in token".to_string()))?; + Ok((UserId::from_uuid(uuid), role)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn issue_and_verify_roundtrip() { + let issuer = JwtTokenIssuer::new("test-secret-key-long-enough-32chars!!"); + let user_id = UserId::new(); + let token = issuer.issue(&user_id, &Role::User).await.unwrap(); + let (verified_id, verified_role) = issuer.verify(&token).await.unwrap(); + assert_eq!(verified_id, user_id); + assert_eq!(verified_role, Role::User); + } + + #[tokio::test] + async fn rejects_invalid_token() { + let issuer = JwtTokenIssuer::new("test-secret-key-long-enough-32chars!!"); + let result = issuer.verify("not.a.valid.jwt").await; + assert!(matches!(result, Err(DomainError::Unauthorized(_)))); + } +} diff --git a/crates/adapters/auth/src/lib.rs b/crates/adapters/auth/src/lib.rs new file mode 100644 index 0000000..4e7ef5d --- /dev/null +++ b/crates/adapters/auth/src/lib.rs @@ -0,0 +1,7 @@ +pub mod jwt; + +pub mod password; + +pub use jwt::JwtTokenIssuer; + +pub use password::BcryptPasswordHasher; diff --git a/crates/adapters/auth/src/password.rs b/crates/adapters/auth/src/password.rs new file mode 100644 index 0000000..0d688fc --- /dev/null +++ b/crates/adapters/auth/src/password.rs @@ -0,0 +1,38 @@ +use async_trait::async_trait; +use domain::{errors::DomainError, ports::PasswordHasher, value_objects::PasswordHash}; + +pub struct BcryptPasswordHasher; + +#[async_trait] +impl PasswordHasher for BcryptPasswordHasher { + async fn hash(&self, password: &str) -> Result { + let password = password.to_owned(); + let hash = tokio::task::spawn_blocking(move || bcrypt::hash(&password, 12)) + .await + .map_err(|e| DomainError::Internal(e.to_string()))? + .map_err(|e| DomainError::Internal(e.to_string()))?; + Ok(PasswordHash::from_hash(hash)) + } + + async fn verify(&self, password: &str, hash: &PasswordHash) -> Result { + let password = password.to_owned(); + let hash = hash.as_str().to_owned(); + tokio::task::spawn_blocking(move || bcrypt::verify(&password, &hash)) + .await + .map_err(|e| DomainError::Internal(e.to_string()))? + .map_err(|e| DomainError::Internal(e.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn hash_and_verify_roundtrip() { + let h = BcryptPasswordHasher; + let hash = h.hash("mysecretpassword").await.unwrap(); + assert!(h.verify("mysecretpassword", &hash).await.unwrap()); + assert!(!h.verify("wrongpassword", &hash).await.unwrap()); + } +} diff --git a/crates/adapters/postgres/Cargo.toml b/crates/adapters/postgres/Cargo.toml new file mode 100644 index 0000000..0961e66 --- /dev/null +++ b/crates/adapters/postgres/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "adapters-postgres" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +sqlx = { workspace = true, features = ["postgres"] } +uuid = { workspace = true } +chrono = { workspace = true } +anyhow = { workspace = true } +async-trait = { workspace = true } diff --git a/crates/adapters/postgres/migrations/001_init_users.sql b/crates/adapters/postgres/migrations/001_init_users.sql new file mode 100644 index 0000000..cea3195 --- /dev/null +++ b/crates/adapters/postgres/migrations/001_init_users.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/crates/adapters/postgres/src/db.rs b/crates/adapters/postgres/src/db.rs new file mode 100644 index 0000000..75e9f37 --- /dev/null +++ b/crates/adapters/postgres/src/db.rs @@ -0,0 +1,14 @@ +pub type PgPool = sqlx::PgPool; + +pub async fn connect(url: &str) -> anyhow::Result { + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(10) + .connect(url) + .await?; + Ok(pool) +} + +pub async fn run_migrations(pool: &PgPool) -> anyhow::Result<()> { + sqlx::migrate!("./migrations").run(pool).await?; + Ok(()) +} diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs new file mode 100644 index 0000000..6616b69 --- /dev/null +++ b/crates/adapters/postgres/src/lib.rs @@ -0,0 +1,5 @@ +pub mod db; +pub mod user_repository; + +pub use db::{connect, run_migrations, PgPool}; +pub use user_repository::PostgresUserRepository; diff --git a/crates/adapters/postgres/src/user_repository.rs b/crates/adapters/postgres/src/user_repository.rs new file mode 100644 index 0000000..fb5e2be --- /dev/null +++ b/crates/adapters/postgres/src/user_repository.rs @@ -0,0 +1,86 @@ +use async_trait::async_trait; +use domain::{ + entities::User, + errors::DomainError, + ports::UserRepository, + value_objects::{Email, PasswordHash, Role, UserId}, +}; +use std::str::FromStr; +use crate::db::PgPool; + +pub struct PostgresUserRepository { + pool: PgPool, +} + +impl PostgresUserRepository { + pub fn new(pool: PgPool) -> Self { Self { pool } } +} + +#[async_trait] +impl UserRepository for PostgresUserRepository { + async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { + let row = sqlx::query!( + "SELECT id, email, password_hash, role, created_at FROM users WHERE id = $1", + *id.as_uuid() + ) + .fetch_optional(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + + row.map(|r| Ok(User { + id: UserId::from_uuid(r.id), + email: Email::new(r.email)?, + password_hash: PasswordHash::from_hash(r.password_hash), + role: Role::from_str(&r.role).map_err(DomainError::Internal)?, + created_at: r.created_at, + })) + .transpose() + } + + async fn find_by_email(&self, email: &Email) -> Result, DomainError> { + let row = sqlx::query!( + "SELECT id, email, password_hash, role, created_at FROM users WHERE email = $1", + email.as_str() + ) + .fetch_optional(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + + row.map(|r| Ok(User { + id: UserId::from_uuid(r.id), + email: Email::new(r.email)?, + password_hash: PasswordHash::from_hash(r.password_hash), + role: Role::from_str(&r.role).map_err(DomainError::Internal)?, + created_at: r.created_at, + })) + .transpose() + } + + async fn save(&self, user: &User) -> Result<(), DomainError> { + sqlx::query!( + "INSERT INTO users (id, email, password_hash, role, created_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + role = EXCLUDED.role", + *user.id.as_uuid(), + user.email.as_str(), + user.password_hash.as_str(), + user.role.to_string(), + user.created_at + ) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + Ok(()) + } + + async fn delete(&self, id: &UserId) -> Result<(), DomainError> { + sqlx::query!("DELETE FROM users WHERE id = $1", *id.as_uuid()) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + Ok(()) + } +} 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/api-types/Cargo.toml b/crates/api-types/Cargo.toml new file mode 100644 index 0000000..a88a94c --- /dev/null +++ b/crates/api-types/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "api-types" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +serde = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +utoipa = { workspace = true } diff --git a/crates/api-types/src/lib.rs b/crates/api-types/src/lib.rs new file mode 100644 index 0000000..116da0f --- /dev/null +++ b/crates/api-types/src/lib.rs @@ -0,0 +1,2 @@ +pub mod requests; +pub mod responses; diff --git a/crates/api-types/src/requests.rs b/crates/api-types/src/requests.rs new file mode 100644 index 0000000..5e67edf --- /dev/null +++ b/crates/api-types/src/requests.rs @@ -0,0 +1,11 @@ +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct RegisterRequest { + pub email: String, + pub password: String, +} + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} diff --git a/crates/api-types/src/responses.rs b/crates/api-types/src/responses.rs new file mode 100644 index 0000000..0a9f7ee --- /dev/null +++ b/crates/api-types/src/responses.rs @@ -0,0 +1,27 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct UserResponse { + pub id: Uuid, + pub email: String, + pub role: String, + pub created_at: DateTime, +} + +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct AuthResponse { + pub token: String, + pub user: UserResponse, +} + +impl UserResponse { + pub fn from_domain(user: &domain::entities::User) -> Self { + Self { + id: *user.id.as_uuid(), + email: user.email.to_string(), + role: user.role.to_string(), + created_at: user.created_at, + } + } +} diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml new file mode 100644 index 0000000..1fb2d49 --- /dev/null +++ b/crates/application/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "application" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +async-trait = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } +tokio = { workspace = true } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs new file mode 100644 index 0000000..0435007 --- /dev/null +++ b/crates/application/src/lib.rs @@ -0,0 +1,2 @@ +pub mod testing; +pub mod use_cases; diff --git a/crates/application/src/testing.rs b/crates/application/src/testing.rs new file mode 100644 index 0000000..1d27ddd --- /dev/null +++ b/crates/application/src/testing.rs @@ -0,0 +1,79 @@ +use std::collections::HashMap; +use async_trait::async_trait; +use tokio::sync::Mutex; +use domain::{ + entities::User, + errors::DomainError, + ports::{PasswordHasher, TokenIssuer, UserRepository}, + value_objects::{Email, PasswordHash, Role, UserId}, +}; + +pub struct InMemoryUserRepository { + users: Mutex>, +} + +impl InMemoryUserRepository { + pub fn new() -> Self { + Self { users: Mutex::new(HashMap::new()) } + } + + pub async fn all(&self) -> Vec { + self.users.lock().await.values().cloned().collect() + } +} + +impl Default for InMemoryUserRepository { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl UserRepository for InMemoryUserRepository { + async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { + Ok(self.users.lock().await.get(&id.to_string()).cloned()) + } + + async fn find_by_email(&self, email: &Email) -> Result, DomainError> { + Ok(self.users.lock().await.values() + .find(|u| u.email.as_str() == email.as_str()) + .cloned()) + } + + async fn save(&self, user: &User) -> Result<(), DomainError> { + self.users.lock().await.insert(user.id.to_string(), user.clone()); + Ok(()) + } + + async fn delete(&self, id: &UserId) -> Result<(), DomainError> { + self.users.lock().await.remove(&id.to_string()); + Ok(()) + } +} + +pub struct StubPasswordHasher; + +#[async_trait] +impl PasswordHasher for StubPasswordHasher { + async fn hash(&self, password: &str) -> Result { + Ok(PasswordHash::from_hash(format!("hashed:{password}"))) + } + async fn verify(&self, password: &str, hash: &PasswordHash) -> Result { + Ok(hash.as_str() == format!("hashed:{password}")) + } +} + +pub struct StubTokenIssuer; + +#[async_trait] +impl TokenIssuer for StubTokenIssuer { + async fn issue(&self, user_id: &UserId, _role: &Role) -> Result { + Ok(format!("token:{user_id}")) + } + async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError> { + let id_str = token.strip_prefix("token:").ok_or_else(|| { + DomainError::Unauthorized("Invalid stub token".to_string()) + })?; + let uuid = uuid::Uuid::parse_str(id_str) + .map_err(|_| DomainError::Unauthorized("Bad UUID in stub token".to_string()))?; + Ok((UserId::from_uuid(uuid), Role::User)) + } +} diff --git a/crates/application/src/use_cases/get_profile.rs b/crates/application/src/use_cases/get_profile.rs new file mode 100644 index 0000000..1d7c687 --- /dev/null +++ b/crates/application/src/use_cases/get_profile.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; +use domain::{entities::User, errors::DomainError, ports::UserRepository, value_objects::UserId}; + +pub struct GetProfile { + repo: Arc, +} + +impl GetProfile { + pub fn new(repo: Arc) -> Self { Self { repo } } + + pub async fn execute(&self, user_id: &UserId) -> Result { + self.repo.find_by_id(user_id).await? + .ok_or_else(|| DomainError::NotFound(format!("User {user_id} not found"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{InMemoryUserRepository, StubPasswordHasher}; + use crate::use_cases::register::RegisterUser; + + #[tokio::test] + async fn get_profile_returns_existing_user() { + let repo = Arc::new(InMemoryUserRepository::new()); + let r = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher)); + let user = r.execute("user@example.com", "password123").await.unwrap(); + let uc = GetProfile::new(repo); + let found = uc.execute(&user.id).await.unwrap(); + assert_eq!(found.id, user.id); + } + + #[tokio::test] + async fn get_profile_returns_not_found() { + let repo = Arc::new(InMemoryUserRepository::new()); + let uc = GetProfile::new(repo); + let result = uc.execute(&UserId::new()).await; + assert!(matches!(result, Err(DomainError::NotFound(_)))); + } +} diff --git a/crates/application/src/use_cases/login.rs b/crates/application/src/use_cases/login.rs new file mode 100644 index 0000000..fb3355f --- /dev/null +++ b/crates/application/src/use_cases/login.rs @@ -0,0 +1,74 @@ +use std::sync::Arc; +use domain::{ + entities::User, + errors::DomainError, + ports::{PasswordHasher, TokenIssuer, UserRepository}, + value_objects::Email, +}; + +pub struct LoginUser { + repo: Arc, + hasher: Arc, + issuer: Arc, +} + +impl LoginUser { + pub fn new( + repo: Arc, + hasher: Arc, + issuer: Arc, + ) -> Self { + Self { repo, hasher, issuer } + } + + pub async fn execute(&self, email: &str, password: &str) -> Result<(User, String), DomainError> { + let email = Email::new(email)?; + let user = self.repo.find_by_email(&email).await? + .ok_or_else(|| DomainError::Unauthorized("Invalid credentials".to_string()))?; + let valid = self.hasher.verify(password, &user.password_hash).await?; + if !valid { + return Err(DomainError::Unauthorized("Invalid credentials".to_string())); + } + let token = self.issuer.issue(&user.id, &user.role).await?; + Ok((user, token)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{InMemoryUserRepository, StubPasswordHasher, StubTokenIssuer}; + use crate::use_cases::register::RegisterUser; + + async fn seeded_repo() -> Arc { + let repo = Arc::new(InMemoryUserRepository::new()); + let r = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher)); + r.execute("user@example.com", "password123").await.unwrap(); + repo + } + + #[tokio::test] + async fn login_returns_user_and_token() { + let repo = seeded_repo().await; + let uc = LoginUser::new(repo, Arc::new(StubPasswordHasher), Arc::new(StubTokenIssuer)); + let (user, token) = uc.execute("user@example.com", "password123").await.unwrap(); + assert_eq!(user.email.as_str(), "user@example.com"); + assert!(token.starts_with("token:")); + } + + #[tokio::test] + async fn login_rejects_wrong_password() { + let repo = seeded_repo().await; + let uc = LoginUser::new(repo, Arc::new(StubPasswordHasher), Arc::new(StubTokenIssuer)); + let result = uc.execute("user@example.com", "wrongpassword").await; + assert!(matches!(result, Err(DomainError::Unauthorized(_)))); + } + + #[tokio::test] + async fn login_rejects_unknown_email() { + let repo = seeded_repo().await; + let uc = LoginUser::new(repo, Arc::new(StubPasswordHasher), Arc::new(StubTokenIssuer)); + let result = uc.execute("nobody@example.com", "password123").await; + assert!(matches!(result, Err(DomainError::Unauthorized(_)))); + } +} diff --git a/crates/application/src/use_cases/mod.rs b/crates/application/src/use_cases/mod.rs new file mode 100644 index 0000000..0427641 --- /dev/null +++ b/crates/application/src/use_cases/mod.rs @@ -0,0 +1,7 @@ +pub mod get_profile; +pub mod login; +pub mod register; + +pub use get_profile::GetProfile; +pub use login::LoginUser; +pub use register::RegisterUser; diff --git a/crates/application/src/use_cases/register.rs b/crates/application/src/use_cases/register.rs new file mode 100644 index 0000000..341b40a --- /dev/null +++ b/crates/application/src/use_cases/register.rs @@ -0,0 +1,72 @@ +use std::sync::Arc; +use domain::{ + entities::User, + errors::DomainError, + ports::{PasswordHasher, UserRepository}, + value_objects::{Email, UserId}, +}; + +pub struct RegisterUser { + repo: Arc, + hasher: Arc, +} + +impl RegisterUser { + pub fn new(repo: Arc, hasher: Arc) -> Self { + Self { repo, hasher } + } + + pub async fn execute(&self, email: &str, password: &str) -> Result { + if password.len() < 8 { + return Err(DomainError::Validation("Password must be at least 8 characters".to_string())); + } + let email = Email::new(email)?; + if self.repo.find_by_email(&email).await?.is_some() { + return Err(DomainError::Conflict(format!("Email {} is already registered", email.as_str()))); + } + let hash = self.hasher.hash(password).await?; + let user = User::new(UserId::new(), email, hash); + self.repo.save(&user).await?; + Ok(user) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{InMemoryUserRepository, StubPasswordHasher}; + + #[tokio::test] + async fn register_creates_user() { + let repo = Arc::new(InMemoryUserRepository::new()); + let uc = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher)); + let user = uc.execute("test@example.com", "password123").await.unwrap(); + assert_eq!(user.email.as_str(), "test@example.com"); + assert_eq!(repo.all().await.len(), 1); + } + + #[tokio::test] + async fn register_rejects_duplicate_email() { + let repo = Arc::new(InMemoryUserRepository::new()); + let uc = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher)); + uc.execute("test@example.com", "password123").await.unwrap(); + let result = uc.execute("test@example.com", "different1").await; + assert!(matches!(result, Err(DomainError::Conflict(_)))); + } + + #[tokio::test] + async fn register_rejects_short_password() { + let repo = Arc::new(InMemoryUserRepository::new()); + let uc = RegisterUser::new(repo, Arc::new(StubPasswordHasher)); + let result = uc.execute("test@example.com", "short").await; + assert!(matches!(result, Err(DomainError::Validation(_)))); + } + + #[tokio::test] + async fn register_rejects_invalid_email() { + let repo = Arc::new(InMemoryUserRepository::new()); + let uc = RegisterUser::new(repo, Arc::new(StubPasswordHasher)); + let result = uc.execute("notanemail", "password123").await; + assert!(matches!(result, Err(DomainError::Validation(_)))); + } +} diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml new file mode 100644 index 0000000..9ef10b8 --- /dev/null +++ b/crates/bootstrap/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "bootstrap" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "k_photos" +path = "src/main.rs" + +[dependencies] +domain = { workspace = true } +application = { workspace = true } +adapters-auth = { workspace = true } + +adapters-storage = { workspace = true, features = ["s3"] } + +presentation = { workspace = true } + + +adapters-postgres = { path = "../adapters/postgres" } + +tokio = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +dotenvy = { workspace = true } +tower-http = { workspace = true } +axum = { workspace = true } diff --git a/crates/bootstrap/src/config.rs b/crates/bootstrap/src/config.rs new file mode 100644 index 0000000..6b8ee02 --- /dev/null +++ b/crates/bootstrap/src/config.rs @@ -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 new file mode 100644 index 0000000..0c2597b --- /dev/null +++ b/crates/bootstrap/src/factory.rs @@ -0,0 +1,58 @@ +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}; + + +use adapters_postgres::{connect, run_migrations, PostgresUserRepository}; + + +use adapters_storage::{ObjectStorageAdapter, StorageConfig, build_store}; + +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?; + + + + let user_repo = Arc::new(PostgresUserRepository::new(pool)); + + 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)); + + + 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( + 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/bootstrap/src/lib.rs b/crates/bootstrap/src/lib.rs new file mode 100644 index 0000000..e69de29 diff --git a/crates/bootstrap/src/main.rs b/crates/bootstrap/src/main.rs new file mode 100644 index 0000000..85117f1 --- /dev/null +++ b/crates/bootstrap/src/main.rs @@ -0,0 +1,28 @@ +use std::net::SocketAddr; +use tracing::info; + +mod config; +mod factory; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("bootstrap=info".parse()?) + .add_directive("tower_http=debug".parse()?), + ) + .init(); + + let config = config::Config::from_env(); + let app = factory::build_app(&config).await?; + + let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?; + let listener = tokio::net::TcpListener::bind(addr).await?; + + info!("🚀 Server running at http://{addr}"); + info!("📖 Scalar docs at http://{addr}/scalar"); + + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml new file mode 100644 index 0000000..fc590b6 --- /dev/null +++ b/crates/domain/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "domain" +version = "0.1.0" +edition = "2024" + +[dependencies] +uuid = { workspace = true } +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/entities/mod.rs b/crates/domain/src/entities/mod.rs new file mode 100644 index 0000000..7068ef2 --- /dev/null +++ b/crates/domain/src/entities/mod.rs @@ -0,0 +1,2 @@ +mod user; +pub use user::User; diff --git a/crates/domain/src/entities/user.rs b/crates/domain/src/entities/user.rs new file mode 100644 index 0000000..3eae318 --- /dev/null +++ b/crates/domain/src/entities/user.rs @@ -0,0 +1,17 @@ +use chrono::{DateTime, Utc}; +use crate::value_objects::{Email, PasswordHash, Role, UserId}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct User { + pub id: UserId, + pub email: Email, + pub password_hash: PasswordHash, + pub role: Role, + pub created_at: DateTime, +} + +impl User { + pub fn new(id: UserId, email: Email, password_hash: PasswordHash) -> Self { + Self { id, email, password_hash, role: Role::User, created_at: Utc::now() } + } +} diff --git a/crates/domain/src/errors.rs b/crates/domain/src/errors.rs new file mode 100644 index 0000000..17d04ed --- /dev/null +++ b/crates/domain/src/errors.rs @@ -0,0 +1,13 @@ +#[derive(Debug, thiserror::Error)] +pub enum DomainError { + #[error("Not found: {0}")] + NotFound(String), + #[error("Conflict: {0}")] + Conflict(String), + #[error("Unauthorized: {0}")] + Unauthorized(String), + #[error("Validation error: {0}")] + Validation(String), + #[error("Internal error: {0}")] + Internal(String), +} diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs new file mode 100644 index 0000000..63bf242 --- /dev/null +++ b/crates/domain/src/events.rs @@ -0,0 +1,7 @@ +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub enum DomainEvent { + UserRegistered { user_id: Uuid }, + UserLoggedIn { user_id: Uuid }, +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs new file mode 100644 index 0000000..3df2e20 --- /dev/null +++ b/crates/domain/src/lib.rs @@ -0,0 +1,5 @@ +pub mod entities; +pub mod errors; +pub mod events; +pub mod ports; +pub mod value_objects; diff --git a/crates/domain/src/ports/auth.rs b/crates/domain/src/ports/auth.rs new file mode 100644 index 0000000..c93f0e9 --- /dev/null +++ b/crates/domain/src/ports/auth.rs @@ -0,0 +1,14 @@ +use async_trait::async_trait; +use crate::{errors::DomainError, value_objects::{PasswordHash, Role, UserId}}; + +#[async_trait] +pub trait PasswordHasher: Send + Sync { + async fn hash(&self, password: &str) -> Result; + async fn verify(&self, password: &str, hash: &PasswordHash) -> Result; +} + +#[async_trait] +pub trait TokenIssuer: Send + Sync { + async fn issue(&self, user_id: &UserId, role: &Role) -> Result; + async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError>; +} diff --git a/crates/domain/src/ports/mod.rs b/crates/domain/src/ports/mod.rs new file mode 100644 index 0000000..2370d49 --- /dev/null +++ b/crates/domain/src/ports/mod.rs @@ -0,0 +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/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/domain/src/ports/user_repo.rs b/crates/domain/src/ports/user_repo.rs new file mode 100644 index 0000000..07dc7b8 --- /dev/null +++ b/crates/domain/src/ports/user_repo.rs @@ -0,0 +1,10 @@ +use async_trait::async_trait; +use crate::{entities::User, errors::DomainError, value_objects::{Email, UserId}}; + +#[async_trait] +pub trait UserRepository: Send + Sync { + async fn find_by_id(&self, id: &UserId) -> Result, DomainError>; + async fn find_by_email(&self, email: &Email) -> Result, DomainError>; + async fn save(&self, user: &User) -> Result<(), DomainError>; + async fn delete(&self, id: &UserId) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/value_objects/email.rs b/crates/domain/src/value_objects/email.rs new file mode 100644 index 0000000..87898a3 --- /dev/null +++ b/crates/domain/src/value_objects/email.rs @@ -0,0 +1,42 @@ +use crate::errors::DomainError; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Email(String); + +impl Email { + pub fn new(value: impl Into) -> Result { + let value = value.into().trim().to_lowercase(); + if value.is_empty() || !value.contains('@') { + return Err(DomainError::Validation("Invalid email address".to_string())); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { &self.0 } +} + +impl std::fmt::Display for Email { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_empty() { assert!(Email::new("").is_err()); } + + #[test] + fn rejects_no_at() { assert!(Email::new("notanemail").is_err()); } + + #[test] + fn accepts_valid() { assert!(Email::new("user@example.com").is_ok()); } + + #[test] + fn lowercases_and_trims() { + let email = Email::new(" User@Example.Com ").unwrap(); + assert_eq!(email.as_str(), "user@example.com"); + } +} diff --git a/crates/domain/src/value_objects/mod.rs b/crates/domain/src/value_objects/mod.rs new file mode 100644 index 0000000..4624615 --- /dev/null +++ b/crates/domain/src/value_objects/mod.rs @@ -0,0 +1,9 @@ +mod email; +mod password; +mod role; +mod user_id; + +pub use email::Email; +pub use password::PasswordHash; +pub use role::Role; +pub use user_id::UserId; diff --git a/crates/domain/src/value_objects/password.rs b/crates/domain/src/value_objects/password.rs new file mode 100644 index 0000000..072a3c7 --- /dev/null +++ b/crates/domain/src/value_objects/password.rs @@ -0,0 +1,14 @@ +// Manual Debug — redacts hash to prevent it appearing in logs +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub struct PasswordHash(String); + +impl std::fmt::Debug for PasswordHash { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("PasswordHash").field(&"[redacted]").finish() + } +} + +impl PasswordHash { + pub fn from_hash(hash: String) -> Self { Self(hash) } + pub fn as_str(&self) -> &str { &self.0 } +} diff --git a/crates/domain/src/value_objects/role.rs b/crates/domain/src/value_objects/role.rs new file mode 100644 index 0000000..c4efe7b --- /dev/null +++ b/crates/domain/src/value_objects/role.rs @@ -0,0 +1,23 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { User, Admin } + +impl std::fmt::Display for Role { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Role::User => write!(f, "user"), + Role::Admin => write!(f, "admin"), + } + } +} + +impl std::str::FromStr for Role { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "user" => Ok(Role::User), + "admin" => Ok(Role::Admin), + other => Err(format!("Unknown role: {other}")), + } + } +} diff --git a/crates/domain/src/value_objects/user_id.rs b/crates/domain/src/value_objects/user_id.rs new file mode 100644 index 0000000..0d30683 --- /dev/null +++ b/crates/domain/src/value_objects/user_id.rs @@ -0,0 +1,22 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct UserId(uuid::Uuid); + +impl UserId { + pub fn new() -> Self { Self(uuid::Uuid::new_v4()) } + pub fn from_uuid(id: uuid::Uuid) -> Self { Self(id) } + pub fn as_uuid(&self) -> &uuid::Uuid { &self.0 } +} + +impl Default for UserId { + fn default() -> Self { Self::new() } +} + +impl std::fmt::Display for UserId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for UserId { + fn from(id: uuid::Uuid) -> Self { Self(id) } +} diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml new file mode 100644 index 0000000..50583da --- /dev/null +++ b/crates/presentation/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "presentation" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +application = { workspace = true } +api-types = { path = "../api-types" } +axum = { workspace = true } +tower-http = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +tracing = { workspace = true } +async-trait = { workspace = true } +utoipa = { workspace = true } +utoipa-scalar = { workspace = true } diff --git a/crates/presentation/src/errors.rs b/crates/presentation/src/errors.rs new file mode 100644 index 0000000..9d65efe --- /dev/null +++ b/crates/presentation/src/errors.rs @@ -0,0 +1,25 @@ +use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; +use domain::errors::DomainError; +use serde_json::json; + +pub struct AppError(DomainError); + +impl From for AppError { + fn from(e: DomainError) -> Self { Self(e) } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match &self.0 { + DomainError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), + DomainError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()), + DomainError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()), + DomainError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.clone()), + DomainError::Internal(msg) => { + tracing::error!("Internal error: {msg}"); + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string()) + } + }; + (status, Json(json!({ "error": message }))).into_response() + } +} diff --git a/crates/presentation/src/extractors/auth.rs b/crates/presentation/src/extractors/auth.rs new file mode 100644 index 0000000..7fd01c6 --- /dev/null +++ b/crates/presentation/src/extractors/auth.rs @@ -0,0 +1,38 @@ +use axum::{ + extract::FromRequestParts, + http::{request::Parts, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use domain::value_objects::{Role, UserId}; +use serde_json::json; +use crate::state::AppState; + +pub struct JwtClaims { + pub user_id: UserId, + pub role: Role, +} + +impl FromRequestParts for JwtClaims { + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + let auth_header = parts + .headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + (StatusCode::UNAUTHORIZED, Json(json!({ "error": "Missing Authorization header" }))).into_response() + })?; + + let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| { + (StatusCode::UNAUTHORIZED, Json(json!({ "error": "Invalid Authorization format" }))).into_response() + })?; + + let (user_id, role) = state.token_issuer.verify(token).await.map_err(|_| { + (StatusCode::UNAUTHORIZED, Json(json!({ "error": "Invalid or expired token" }))).into_response() + })?; + + Ok(JwtClaims { user_id, role }) + } +} diff --git a/crates/presentation/src/extractors/json.rs b/crates/presentation/src/extractors/json.rs new file mode 100644 index 0000000..3beaa54 --- /dev/null +++ b/crates/presentation/src/extractors/json.rs @@ -0,0 +1,28 @@ +use axum::{ + extract::{rejection::JsonRejection, FromRequest, Request}, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::de::DeserializeOwned; +use serde_json::json; + +pub struct ValidatedJson(pub T); + +impl FromRequest for ValidatedJson +where + T: DeserializeOwned, + S: Send + Sync, + Json: FromRequest, +{ + type Rejection = Response; + + async fn from_request(req: Request, state: &S) -> Result { + Json::::from_request(req, state) + .await + .map(|Json(value)| ValidatedJson(value)) + .map_err(|rejection| { + (StatusCode::UNPROCESSABLE_ENTITY, Json(json!({ "error": rejection.body_text() }))).into_response() + }) + } +} diff --git a/crates/presentation/src/extractors/mod.rs b/crates/presentation/src/extractors/mod.rs new file mode 100644 index 0000000..f7e87be --- /dev/null +++ b/crates/presentation/src/extractors/mod.rs @@ -0,0 +1,5 @@ +pub mod auth; +pub mod json; + +pub use auth::JwtClaims; +pub use json::ValidatedJson; diff --git a/crates/presentation/src/handlers/auth.rs b/crates/presentation/src/handlers/auth.rs new file mode 100644 index 0000000..8da74f2 --- /dev/null +++ b/crates/presentation/src/handlers/auth.rs @@ -0,0 +1,56 @@ +use axum::{extract::State, http::StatusCode, Json}; +use api_types::{ + requests::{LoginRequest, RegisterRequest}, + responses::{AuthResponse, UserResponse}, +}; +use crate::{errors::AppError, extractors::{JwtClaims, ValidatedJson}, state::AppState}; + +#[utoipa::path( + post, path = "/api/v1/auth/register", + request_body = RegisterRequest, + responses( + (status = 201, description = "User registered", body = AuthResponse), + (status = 409, description = "Email already taken"), + (status = 422, description = "Validation error") + ) +)] +pub async fn register( + State(state): State, + ValidatedJson(req): ValidatedJson, +) -> Result<(StatusCode, Json), AppError> { + let user = state.register_uc.execute(&req.email, &req.password).await?; + let token = state.token_issuer.issue(&user.id, &user.role).await.map_err(AppError::from)?; + Ok((StatusCode::CREATED, Json(AuthResponse { token, user: UserResponse::from_domain(&user) }))) +} + +#[utoipa::path( + post, path = "/api/v1/auth/login", + request_body = LoginRequest, + responses( + (status = 200, description = "Login successful", body = AuthResponse), + (status = 401, description = "Invalid credentials") + ) +)] +pub async fn login( + State(state): State, + ValidatedJson(req): ValidatedJson, +) -> Result, AppError> { + let (user, token) = state.login_uc.execute(&req.email, &req.password).await?; + Ok(Json(AuthResponse { token, user: UserResponse::from_domain(&user) })) +} + +#[utoipa::path( + get, path = "/api/v1/auth/me", + security(("bearer_token" = [])), + responses( + (status = 200, description = "Current user profile", body = UserResponse), + (status = 401, description = "Unauthorized") + ) +)] +pub async fn me( + State(state): State, + claims: JwtClaims, +) -> Result, AppError> { + let user = state.get_profile_uc.execute(&claims.user_id).await?; + Ok(Json(UserResponse::from_domain(&user))) +} diff --git a/crates/presentation/src/handlers/health.rs b/crates/presentation/src/handlers/health.rs new file mode 100644 index 0000000..db3a40e --- /dev/null +++ b/crates/presentation/src/handlers/health.rs @@ -0,0 +1,7 @@ +use axum::{http::StatusCode, Json}; +use serde_json::json; + +#[utoipa::path(get, path = "/health", responses((status = 200, description = "Service is healthy")))] +pub async fn health() -> (StatusCode, Json) { + (StatusCode::OK, Json(json!({ "status": "ok" }))) +} diff --git a/crates/presentation/src/handlers/mod.rs b/crates/presentation/src/handlers/mod.rs new file mode 100644 index 0000000..a923656 --- /dev/null +++ b/crates/presentation/src/handlers/mod.rs @@ -0,0 +1,2 @@ +pub mod auth; +pub mod health; 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/lib.rs b/crates/presentation/src/lib.rs new file mode 100644 index 0000000..fa5838a --- /dev/null +++ b/crates/presentation/src/lib.rs @@ -0,0 +1,6 @@ +pub mod errors; +pub mod extractors; +pub mod handlers; +pub mod openapi; +pub mod routes; +pub mod state; diff --git a/crates/presentation/src/openapi/mod.rs b/crates/presentation/src/openapi/mod.rs new file mode 100644 index 0000000..f8cf261 --- /dev/null +++ b/crates/presentation/src/openapi/mod.rs @@ -0,0 +1,41 @@ +use utoipa::{openapi::security::{Http, HttpAuthScheme, SecurityScheme}, Modify, OpenApi}; +use utoipa_scalar::{Scalar, Servable}; +use axum::Router; +use crate::state::AppState; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::handlers::health::health, + crate::handlers::auth::register, + crate::handlers::auth::login, + crate::handlers::auth::me, + ), + components(schemas( + api_types::requests::RegisterRequest, + api_types::requests::LoginRequest, + api_types::responses::AuthResponse, + api_types::responses::UserResponse, + )), + modifiers(&SecurityAddon), + info(title = "k-template", version = "0.1.0") +)] +pub struct ApiDoc; + +struct SecurityAddon; +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "bearer_token", + SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)), + ); + } + } +} + +pub fn openapi_router() -> Router { + Router::new() + .merge(Scalar::with_url("/scalar", ApiDoc::openapi())) + .route("/api-docs/openapi.json", axum::routing::get(|| async { axum::Json(ApiDoc::openapi()) })) +} diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs new file mode 100644 index 0000000..8946e08 --- /dev/null +++ b/crates/presentation/src/routes.rs @@ -0,0 +1,16 @@ +use axum::{routing::{get, post}, Router}; +use crate::{handlers::{auth, health}, openapi::openapi_router, state::AppState}; + +pub fn api_v1_router() -> Router { + Router::new() + .route("/auth/register", post(auth::register)) + .route("/auth/login", post(auth::login)) + .route("/auth/me", get(auth::me)) +} + +pub fn app_router() -> Router { + Router::new() + .route("/health", get(health::health)) + .nest("/api/v1", api_v1_router()) + .merge(openapi_router()) +} diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs new file mode 100644 index 0000000..2509add --- /dev/null +++ b/crates/presentation/src/state.rs @@ -0,0 +1,26 @@ +use std::sync::Arc; +use application::use_cases::{GetProfile, LoginUser, RegisterUser}; + +use domain::ports::{StoragePort, TokenIssuer}; + + +#[derive(Clone)] +pub struct AppState { + pub register_uc: Arc, + pub login_uc: Arc, + pub get_profile_uc: Arc, + pub token_issuer: Arc, + pub storage: Arc, +} + +impl AppState { + pub fn new( + register_uc: Arc, + login_uc: Arc, + get_profile_uc: Arc, + token_issuer: Arc, + storage: Arc, + ) -> Self { + Self { register_uc, login_uc, get_profile_uc, token_issuer, storage } + } +} diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml new file mode 100644 index 0000000..9d686fa --- /dev/null +++ b/crates/worker/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "worker" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "k_photos-worker" +path = "src/main.rs" + +[dependencies] +domain = { workspace = true } + + +adapters-postgres = { path = "../adapters/postgres" } + +tokio = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +dotenvy = { workspace = true } +async-trait = { workspace = true } diff --git a/crates/worker/src/config.rs b/crates/worker/src/config.rs new file mode 100644 index 0000000..4a176fe --- /dev/null +++ b/crates/worker/src/config.rs @@ -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), + } + } +} diff --git a/crates/worker/src/job.rs b/crates/worker/src/job.rs new file mode 100644 index 0000000..19da8a8 --- /dev/null +++ b/crates/worker/src/job.rs @@ -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<()>; +} diff --git a/crates/worker/src/jobs/example.rs b/crates/worker/src/jobs/example.rs new file mode 100644 index 0000000..ba9784b --- /dev/null +++ b/crates/worker/src/jobs/example.rs @@ -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(()) + } +} diff --git a/crates/worker/src/jobs/mod.rs b/crates/worker/src/jobs/mod.rs new file mode 100644 index 0000000..c03205c --- /dev/null +++ b/crates/worker/src/jobs/mod.rs @@ -0,0 +1,2 @@ +pub mod example; +pub use example::ExampleJob; diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs new file mode 100644 index 0000000..d0244f9 --- /dev/null +++ b/crates/worker/src/main.rs @@ -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(()) +} diff --git a/crates/worker/src/runner.rs b/crates/worker/src/runner.rs new file mode 100644 index 0000000..23b6bc5 --- /dev/null +++ b/crates/worker/src/runner.rs @@ -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, Duration)>, +} + +impl JobRunner { + pub fn new() -> Self { Self { jobs: vec![] } } + + pub fn register(mut self, job: Arc, 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() } }