From e4c582dabeec9bde728c556861d7ad4e793fc84b Mon Sep 17 00:00:00 2001 From: Dmitri Date: Mon, 27 Apr 2026 23:05:27 +0200 Subject: [PATCH] initial db interactions --- ...0bd1e309bd64204925cf73f245c8ce63b27b7.json | 46 +++++ ...e1ef56735331624010eda1703f0169271577c.json | 47 +++++ ...8c18a2399aa845af63214e39040958e3e5ec4.json | 46 +++++ BACKEND_BLUEPRINT.md | 130 ++++++-------- Cargo.lock | 168 +++++++++++++++++- Cargo.toml | 5 +- Dockerfile | 3 - Makefile | 13 ++ migrations/0001_create_users_table.sql | 7 + src/config.rs | 2 +- src/controller/mod.rs | 12 ++ src/controller/model/auth_model.rs | 18 ++ src/controller/model/mod.rs | 1 + src/controller/v1/auth_controller.rs | 28 +++ src/controller/v1/mod.rs | 10 ++ src/database.rs | 4 +- src/db/mod.rs | 2 + src/db/model/mod.rs | 1 + src/db/model/user.rs | 16 ++ src/db/repository/mod.rs | 1 + src/db/repository/user_repository.rs | 35 ++++ src/errors.rs | 30 ++++ src/main.rs | 23 +-- src/server.rs | 21 +++ src/service/auth_service.rs | 15 ++ src/service/mod.rs | 1 + src/state.rs | 6 + 27 files changed, 593 insertions(+), 98 deletions(-) create mode 100644 .sqlx/query-25b92f656255a863ed7649125d60bd1e309bd64204925cf73f245c8ce63b27b7.json create mode 100644 .sqlx/query-a145c5eb33f466fb3112e6feaf0e1ef56735331624010eda1703f0169271577c.json create mode 100644 .sqlx/query-af5f9eb11f896d65c13da7697dc8c18a2399aa845af63214e39040958e3e5ec4.json create mode 100644 Makefile create mode 100644 migrations/0001_create_users_table.sql create mode 100644 src/controller/mod.rs create mode 100644 src/controller/model/auth_model.rs create mode 100644 src/controller/model/mod.rs create mode 100644 src/controller/v1/auth_controller.rs create mode 100644 src/controller/v1/mod.rs create mode 100644 src/db/mod.rs create mode 100644 src/db/model/mod.rs create mode 100644 src/db/model/user.rs create mode 100644 src/db/repository/mod.rs create mode 100644 src/db/repository/user_repository.rs create mode 100644 src/server.rs create mode 100644 src/service/auth_service.rs create mode 100644 src/service/mod.rs create mode 100644 src/state.rs diff --git a/.sqlx/query-25b92f656255a863ed7649125d60bd1e309bd64204925cf73f245c8ce63b27b7.json b/.sqlx/query-25b92f656255a863ed7649125d60bd1e309bd64204925cf73f245c8ce63b27b7.json new file mode 100644 index 0000000..530c651 --- /dev/null +++ b/.sqlx/query-25b92f656255a863ed7649125d60bd1e309bd64204925cf73f245c8ce63b27b7.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "select * from users where email=$1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "password", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "25b92f656255a863ed7649125d60bd1e309bd64204925cf73f245c8ce63b27b7" +} diff --git a/.sqlx/query-a145c5eb33f466fb3112e6feaf0e1ef56735331624010eda1703f0169271577c.json b/.sqlx/query-a145c5eb33f466fb3112e6feaf0e1ef56735331624010eda1703f0169271577c.json new file mode 100644 index 0000000..b28d94a --- /dev/null +++ b/.sqlx/query-a145c5eb33f466fb3112e6feaf0e1ef56735331624010eda1703f0169271577c.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "insert into users (email, password) values ($1, $2) returning *", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "password", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "a145c5eb33f466fb3112e6feaf0e1ef56735331624010eda1703f0169271577c" +} diff --git a/.sqlx/query-af5f9eb11f896d65c13da7697dc8c18a2399aa845af63214e39040958e3e5ec4.json b/.sqlx/query-af5f9eb11f896d65c13da7697dc8c18a2399aa845af63214e39040958e3e5ec4.json new file mode 100644 index 0000000..c511c69 --- /dev/null +++ b/.sqlx/query-af5f9eb11f896d65c13da7697dc8c18a2399aa845af63214e39040958e3e5ec4.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "select * from users where id=$1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "password", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "af5f9eb11f896d65c13da7697dc8c18a2399aa845af63214e39040958e3e5ec4" +} diff --git a/BACKEND_BLUEPRINT.md b/BACKEND_BLUEPRINT.md index fcbb054..1dc7072 100644 --- a/BACKEND_BLUEPRINT.md +++ b/BACKEND_BLUEPRINT.md @@ -15,7 +15,7 @@ - **DB Access:** sqlx (compile-time checked queries, hand-written repositories) - **PostgreSQL Driver/Pool:** sqlx::PgPool - **Logging:** tracing + tracing-subscriber + tracing-tree (structured, hierarchical) -- **Auth:** jsonwebtoken (HS256) + argon2 (password hashing) +- **Auth:** jsonwebtoken (HS256 access tokens, 15min) + opaque refresh tokens (7d, stored hashed in DB via SHA-256) + argon2 (password hashing) - **API Docs:** utoipa (code-first OpenAPI generation) - **Health Probes:** liveness/readiness endpoints - **Deployment:** Docker Compose on self-hosted hardware @@ -25,7 +25,7 @@ ## Decisions Made 1. **JWT signing:** HS256 (shared secret from env, can migrate to RS256 later) -2. **Token model:** Access + Refresh (short-lived access ~15min, long-lived refresh ~7d, refresh tokens stored hashed in DB) +2. **Token model:** Access = JWT (HS256, 15min, stateless verification). Refresh = opaque UUID v4 string, stored SHA-256 hashed in DB (7 days, revocable). Logout deletes refresh token from DB; access token still valid until expiry. 3. **Roles:** Project-level roles (`admin`, `developer`, `reporter`) + Org-level roles (`owner`, `admin`, `member`) 4. **OpenAPI workflow:** Code-first with utoipa (auto-generate spec from Rust handlers/models) 5. **DB access:** sqlx with hand-written repositories (compile-time checked, no ORM) @@ -44,64 +44,37 @@ ``` src/ ├── main.rs # Entrypoint, server, graceful shutdown -├── config.rs # Config (exists, extend) -├── logging.rs # Logging (exists) -├── errors.rs # Unified error types → Axum responses -├── state.rs # AppState (PgPool, config, etc.) -├── routes.rs # Router composition (/api/v1/...) +├── config.rs # Config (db_url, jwt_secret, socket_address, app_env) +├── database.rs # PgPool setup, migration runner +├── logging.rs # tracing config (dev: pretty console, prod: JSON + file) +├── errors.rs # AppError enum → IntoResponse (BadRequest/Unauthorized/Internal) +├── state.rs # AppState (PgPool, jwt_secret) +├── server.rs # Axum server init + graceful shutdown ├── auth/ -│ ├── jwt.rs # HS256 token creation/validation -│ ├── hash.rs # argon2 password hashing -│ ├── handlers.rs # register, login, refresh -│ ├── models.rs # auth DTOs -│ └── service.rs # auth business logic +│ ├── mod.rs +│ ├── jwt.rs # HS256 JWT creation/validation (access tokens, 15min) +│ ├── hash.rs # argon2 password hashing/verification +│ └── token.rs # Refresh token generation (UUID v4) + SHA-256 hashing for DB storage +├── db/ +│ ├── mod.rs +│ ├── user_repo.rs # find_by_email, create +│ └── token_repo.rs # store, find_by_hash, delete_by_hash, delete_all_for_user +├── service/ +│ ├── mod.rs +│ └── auth_service.rs # login, register, refresh, logout business logic +├── controller/ +│ ├── mod.rs # Router composition (/api/v1/...) +│ ├── model/ +│ │ ├── mod.rs +│ │ └── auth_model.rs # LoginRequest, RegisterRequest, AuthResponse DTOs +│ └── v1/ +│ ├── mod.rs # v1 router (nests /auth) +│ └── auth_controller.rs # POST /login, /register, /refresh, /logout handlers ├── middleware/ -│ ├── auth.rs # JWT extraction layer -│ └── rbac.rs # Project-level role guard -├── models/ -│ ├── user.rs -│ ├── org.rs -│ ├── project.rs -│ ├── issue.rs -│ ├── comment.rs -│ ├── tag.rs -│ ├── sprint.rs -│ ├── stage.rs -│ ├── time_entry.rs -│ └── role.rs # OrgRole, ProjectRole enums -├── handlers/ -│ ├── health.rs -│ ├── orgs.rs -│ ├── projects.rs -│ ├── issues.rs -│ ├── comments.rs -│ ├── tags.rs -│ ├── sprints.rs -│ ├── stages.rs -│ └── time_entries.rs -├── services/ -│ ├── org.rs -│ ├── project.rs -│ ├── issue.rs -│ ├── comment.rs -│ ├── tag.rs -│ ├── sprint.rs -│ ├── stage.rs -│ └── time_entry.rs -└── db/ - ├── mod.rs # Pool setup, migration runner - └── repos/ - ├── users.rs - ├── orgs.rs - ├── projects.rs - ├── issues.rs - ├── comments.rs - ├── tags.rs - ├── sprints.rs - ├── stages.rs - ├── time_entries.rs - ├── memberships.rs - └── refresh_tokens.rs +│ ├── auth.rs # JWT extraction from Authorization header, inject CurrentUser +│ └── rbac.rs # Project-level role guard +├── models/ # (future: org, project, issue, etc.) +└── handlers/ # (future: orgs, projects, issues, etc.) migrations/ ├── 001_create_users.sql ├── 002_create_organizations.sql @@ -490,23 +463,36 @@ CREATE INDEX idx_comments_issue ON comments(issue_id); 5. Wire up Axum router with `routes()`, shared state, and graceful shutdown via `tokio::signal` 6. Create first migration: `users` table -### Phase 2: Auth (JWT + Register/Login/Refresh) +### Phase 2: Auth (Hybrid JWT Access + DB-stored Refresh Tokens) -**Additional dependencies:** `jsonwebtoken`, `argon2`, `validator` +**Token model:** Access tokens are signed JWTs (15min, no DB lookup). Refresh tokens are opaque strings stored hashed in DB (7 days, revocable). + +**Additional dependencies:** `jsonwebtoken`, `argon2`, `sha2`, `uuid` (v4), `chrono` + +**Current progress:** +- ✅ Config, AppState (PgPool), DB migrations runner, router setup, users migration +- ✅ Error types (`AppError` with `IntoResponse` — BadRequest/Unauthorized/Internal) +- ✅ Auth controller routes wired (`POST /api/v1/auth/login`, `/register`) +- ✅ Auth DTOs (`LoginRequest`, `RegisterRequest`, `AuthResponse`) in `controller/model/auth_model.rs` +- ✅ Service stubs (`auth_service.rs` — `login()`, `register()` with `todo!()`) +- ⚠️ Handler parameter order needs fix (State before Json) **Tasks:** -1. Create `users` migration (if not done in Phase 1) -2. Create `refresh_tokens` migration -3. Implement `auth::hash` — argon2 password hashing/verification -4. Implement `auth::jwt` — HS256 access + refresh token creation/validation -5. Implement `db::repos::users` — create, get by email, get by id -6. Implement `db::repos::refresh_tokens` — store, find, delete -7. Implement `auth::service` — register, login, refresh logic -8. Implement `auth::handlers` — `POST /api/v1/auth/register`, `/login`, `/refresh` -9. Implement `auth::models` — request/response DTOs with `validator` checks -10. Create `errors.rs` — unified `AppError` enum → `IntoResponse` -11. Create `middleware::auth` — JWT extraction, inject `CurrentUser` -12. Protect routes with auth layer +1. ~~Create `users` migration~~ (done — `0001_create_users_table.sql`) +2. Fix handler parameter order — `State` before `Json` in `auth_controller.rs` +3. Create `tokens` migration — `0002_create_tokens_table.sql` (id, user_id, token_hash, expires_at, created_at) +4. Add dependencies to `Cargo.toml` — `argon2`, `jsonwebtoken`, `sha2`, `uuid` (v4), `chrono` +5. Create `src/db/` module — `user_repo.rs` (find_by_email, create) + `token_repo.rs` (store, find_by_hash, delete_by_hash, delete_all_for_user) +6. Create `src/auth/hash.rs` — argon2 password hashing (`hash_password`, `verify_password`) +7. Create `src/auth/jwt.rs` — HS256 JWT access token creation/validation with 15min expiry (`create_access_token`, `verify_access_token`, `Claims` struct) +8. Create `src/auth/token.rs` — generate random refresh token (UUID v4), SHA-256 hash for DB storage +9. Add `jwt_secret` to `AppState` / `Config` (loaded from `JWT_SECRET` env var) +10. Implement `auth_service.rs` — login (find user → verify password → create JWT access token + generate/store refresh token) and register (check email → hash password → create user → create tokens) +11. Add input validation — email/password not empty → `AppError::Validation` +12. Create `middleware::auth` — JWT extraction from `Authorization: Bearer `, inject `CurrentUser` +13. Add `POST /api/v1/auth/refresh` — accepts refresh token → hash → look up in DB → if valid, delete old + create new token pair +14. Add `POST /api/v1/auth/logout` — accepts refresh token → hash → delete from DB +15. Protect routes with auth layer ### Phase 3: RBAC Layer diff --git a/Cargo.lock b/Cargo.lock index d7e86f4..f85f5ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,27 @@ 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 = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "atoi" version = "2.0.0" @@ -111,6 +132,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -138,12 +168,33 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +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 = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -159,6 +210,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[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" @@ -313,6 +370,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flume" version = "0.11.1" @@ -576,6 +639,30 @@ dependencies = [ "tower-service", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +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.2.0" @@ -913,6 +1000,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1078,8 +1176,11 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rhythm-backend" version = "0.1.0" dependencies = [ + "argon2", "axum", "dotenvy", + "serde", + "serde_json", "sqlx", "thiserror", "tokio", @@ -1224,6 +1325,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1309,6 +1416,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ "base64", "bytes", + "chrono", "crc", "crossbeam-queue", "either", @@ -1329,7 +1437,6 @@ dependencies = [ "sha2", "smallvec", "thiserror", - "time", "tokio", "tokio-stream", "tracing", @@ -1386,6 +1493,7 @@ dependencies = [ "bitflags", "byteorder", "bytes", + "chrono", "crc", "digest", "dotenvy", @@ -1413,7 +1521,6 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror", - "time", "tracing", "uuid", "whoami", @@ -1429,6 +1536,7 @@ dependencies = [ "base64", "bitflags", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -1452,7 +1560,6 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror", - "time", "tracing", "uuid", "whoami", @@ -1465,6 +1572,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", + "chrono", "flume", "futures-channel", "futures-core", @@ -1478,7 +1586,6 @@ dependencies = [ "serde_urlencoded", "sqlx-core", "thiserror", - "time", "tracing", "url", "uuid", @@ -1938,12 +2045,65 @@ dependencies = [ "wasite", ] +[[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" diff --git a/Cargo.toml b/Cargo.toml index 07d461b..fd21668 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,9 @@ tracing-appender = "0.2.5" tracing-subscriber = {version="0.3.23", features = ["env-filter", "json"]} tracing-tree = "0.4.1" tokio = { version = "1.52.1", features = ["rt-multi-thread", "macros", "signal"] } -sqlx = { version = "0.8", features = [ "runtime-tokio", "postgres", "time", "uuid" ] } +sqlx = { version = "0.8", features = [ "runtime-tokio", "postgres", "chrono", "uuid" ] } axum = "0.8.9" thiserror = "2" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +argon2 = "0.5.3" diff --git a/Dockerfile b/Dockerfile index ac73c9d..e5bda92 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,6 @@ FROM rust:1.95.0-alpine3.22 AS builder WORKDIR /app -ARG DB_URL -ARG APP_ENV - COPY . . RUN cargo build --release diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8019205 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +.PHONY: prepare build run clean + +prepare: + cargo sqlx prepare + +build: prepare + cargo build + +run: build + cargo run + +clean: + rm -dfR target diff --git a/migrations/0001_create_users_table.sql b/migrations/0001_create_users_table.sql new file mode 100644 index 0000000..1f4b6cc --- /dev/null +++ b/migrations/0001_create_users_table.sql @@ -0,0 +1,7 @@ +create table users ( + id uuid primary key default uuidv4(), + email varchar(255) unique not null, + password varchar(255) not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); diff --git a/src/config.rs b/src/config.rs index 010741d..1534d10 100644 --- a/src/config.rs +++ b/src/config.rs @@ -35,7 +35,7 @@ impl Config { pub fn load() -> Result { dotenv().ok(); Ok(Self { - db_url: env::var("DB_URL")?, + db_url: env::var("DATABASE_URL")?, socket_address: env::var("SOCKET_ADDRESS")?, app_env: AppEnv::from_env()?, }) diff --git a/src/controller/mod.rs b/src/controller/mod.rs new file mode 100644 index 0000000..a1c810a --- /dev/null +++ b/src/controller/mod.rs @@ -0,0 +1,12 @@ +use axum::{Router, routing::get}; + +use crate::state::AppState; + +pub mod model; +mod v1; + +pub fn router() -> Router { + Router::new() + .route("/", get("Server is going brr 🚀")) + .nest("/api/v1", v1::router_v1()) +} diff --git a/src/controller/model/auth_model.rs b/src/controller/model/auth_model.rs new file mode 100644 index 0000000..e45e548 --- /dev/null +++ b/src/controller/model/auth_model.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} +#[derive(Deserialize)] +pub struct RegisterRequest { + pub email: String, + pub password: String, +} + +#[derive(Serialize)] +pub struct AuthResponse { + pub access_token: String, + pub refresh_token: String, +} diff --git a/src/controller/model/mod.rs b/src/controller/model/mod.rs new file mode 100644 index 0000000..488516f --- /dev/null +++ b/src/controller/model/mod.rs @@ -0,0 +1 @@ +pub mod auth_model; diff --git a/src/controller/v1/auth_controller.rs b/src/controller/v1/auth_controller.rs new file mode 100644 index 0000000..8641755 --- /dev/null +++ b/src/controller/v1/auth_controller.rs @@ -0,0 +1,28 @@ +use axum::extract::State; +use axum::{Json, Router, routing::post}; + +use crate::{ + controller::model::auth_model::{AuthResponse, LoginRequest, RegisterRequest}, + errors::AppError, + service::auth_service::{login, register}, + state::AppState, +}; + +pub fn auth_router() -> Router { + Router::new() + .route("/login", post(login_handler)) + .route("/register", post(register_handler)) +} + +async fn login_handler( + State(s): State, + Json(payload): Json, +) -> Result, AppError> { + login(&s, payload).await +} +async fn register_handler( + State(s): State, + Json(payload): Json, +) -> Result, AppError> { + register(&s, payload).await +} diff --git a/src/controller/v1/mod.rs b/src/controller/v1/mod.rs new file mode 100644 index 0000000..352de45 --- /dev/null +++ b/src/controller/v1/mod.rs @@ -0,0 +1,10 @@ +use axum::Router; + +use crate::state::AppState; + +pub mod auth_controller; + +pub fn router_v1() -> Router { + Router::new().nest("/auth", auth_controller::auth_router()) +} + diff --git a/src/database.rs b/src/database.rs index 95faff3..5c7e16e 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,4 +1,4 @@ -use sqlx::{Pool, Postgres, postgres::PgPoolOptions, migrate::MigrateError}; +use sqlx::{Pool, Postgres, migrate::MigrateError, postgres::PgPoolOptions}; use crate::errors::AppError; @@ -8,7 +8,7 @@ pub async fn init(db_url: &str) -> Result, AppError> { .await .map_err(AppError::DbConnect)?; - sqlx::migrate!() + sqlx::migrate!("./migrations") .run(&db) .await .map_err(|e: MigrateError| AppError::InvalidConfig(format!("Migration failed: {}", e)))?; diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..1442c17 --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,2 @@ +pub mod model; +pub mod repository; diff --git a/src/db/model/mod.rs b/src/db/model/mod.rs new file mode 100644 index 0000000..22d12a3 --- /dev/null +++ b/src/db/model/mod.rs @@ -0,0 +1 @@ +pub mod user; diff --git a/src/db/model/user.rs b/src/db/model/user.rs new file mode 100644 index 0000000..b3ceb72 --- /dev/null +++ b/src/db/model/user.rs @@ -0,0 +1,16 @@ +use sqlx::{ + prelude::FromRow, + types::{ + Uuid, + chrono::{DateTime, Utc}, + }, +}; + +#[derive(Debug, FromRow)] +pub struct User { + pub id: Uuid, + pub email: String, + pub password: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/src/db/repository/mod.rs b/src/db/repository/mod.rs new file mode 100644 index 0000000..a5188ff --- /dev/null +++ b/src/db/repository/mod.rs @@ -0,0 +1 @@ +pub mod user_repository; diff --git a/src/db/repository/user_repository.rs b/src/db/repository/user_repository.rs new file mode 100644 index 0000000..821b9c0 --- /dev/null +++ b/src/db/repository/user_repository.rs @@ -0,0 +1,35 @@ +use sqlx::{PgPool, types::Uuid}; + +use crate::{db::model::user::User, errors::AppError}; + +pub async fn create_user(pool: &PgPool, email: String, password: String) -> Result { + let user = sqlx::query_as!( + User, + "insert into users (email, password) values ($1, $2) returning *", + email, + password + ) + .fetch_one(pool) + .await + .map_err(AppError::from)?; + + Ok(user) +} + +pub async fn get_user_by_email(pool: &PgPool, email: String) -> Result, AppError> { + let user = sqlx::query_as!(User, "select * from users where email=$1", email) + .fetch_optional(pool) + .await + .map_err(AppError::from)?; + + Ok(user) +} + +pub async fn get_user_by_id(pool: &PgPool, id: Uuid) -> Result, AppError> { + let user = sqlx::query_as!(User, "select * from users where id=$1", id) + .fetch_optional(pool) + .await + .map_err(AppError::from)?; + + Ok(user) +} diff --git a/src/errors.rs b/src/errors.rs index 1c3d795..2a4ca54 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,3 +1,8 @@ +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; use thiserror::Error; #[derive(Debug, Error)] @@ -13,6 +18,13 @@ pub enum AppError { #[error("Failed to bind to address")] Bind(#[from] std::io::Error), + + #[error("Invalid credentials")] + InvalidCredentials, + #[error("Validation error: {0}")] + Validation(String), + #[error("Internal server error")] + Internal, } #[derive(Debug, Error)] @@ -24,3 +36,21 @@ impl From for MainError { Self(err) } } + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match &self { + AppError::InvalidCredentials => (StatusCode::UNAUTHORIZED, self.to_string()), + AppError::Validation(_) => (StatusCode::BAD_REQUEST, self.to_string()), + AppError::DbConnect(_) | AppError::Bind(_) | AppError::Internal => ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ), + AppError::Config(_) | AppError::InvalidConfig(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ), + }; + (status, Json(serde_json::json!({ "error": message }))).into_response() + } +} diff --git a/src/main.rs b/src/main.rs index 321b4d5..5f803f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,27 +1,20 @@ -use axum::{Router, routing::get}; - mod config; +mod controller; mod database; +mod db; mod errors; mod logging; - -use errors::{AppError, MainError}; +mod server; +mod service; +mod state; +use errors::MainError; #[tokio::main] async fn main() -> Result<(), MainError> { let cfg = config::Config::load()?; let _logging_guard = logging::LoggerConfig::init(cfg.app_env); - let _db = database::init(&cfg.db_url).await?; - let app = Router::new().route("/", get(|| async { "ciao" })); - let listener = tokio::net::TcpListener::bind(&cfg.socket_address) - .await - .map_err(AppError::Bind)?; - - tracing::info!("Server started on {}", cfg.socket_address); - axum::serve(listener, app) - .with_graceful_shutdown(logging::shutdown_signal()) - .await - .map_err(AppError::Bind)?; + let db = database::init(&cfg.db_url).await?; + server::init(&cfg, db).await?; Ok(()) } diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..d7a0e0e --- /dev/null +++ b/src/server.rs @@ -0,0 +1,21 @@ +use axum::Router; +use sqlx::PgPool; + +use crate::{config, controller, errors::AppError, logging, state::AppState}; + +pub async fn init(cfg: &config::Config, db: PgPool) -> Result<(), AppError> { + let state = AppState { db }; + let app = Router::new().merge(controller::router()).with_state(state); + + let listener = tokio::net::TcpListener::bind(&cfg.socket_address) + .await + .map_err(AppError::Bind)?; + + tracing::info!("Server started on {}", cfg.socket_address); + axum::serve(listener, app) + .with_graceful_shutdown(logging::shutdown_signal()) + .await + .map_err(AppError::Bind)?; + + Ok(()) +} diff --git a/src/service/auth_service.rs b/src/service/auth_service.rs new file mode 100644 index 0000000..445b761 --- /dev/null +++ b/src/service/auth_service.rs @@ -0,0 +1,15 @@ +use axum::Json; + +use crate::controller::model::auth_model::*; +use crate::errors::AppError; +use crate::state::AppState; + +pub async fn login(state: &AppState, req: LoginRequest) -> Result, AppError> { + todo!() +} +pub async fn register( + state: &AppState, + req: RegisterRequest, +) -> Result, AppError> { + todo!() +} diff --git a/src/service/mod.rs b/src/service/mod.rs new file mode 100644 index 0000000..3fe88a6 --- /dev/null +++ b/src/service/mod.rs @@ -0,0 +1 @@ +pub mod auth_service; diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..6b42e8c --- /dev/null +++ b/src/state.rs @@ -0,0 +1,6 @@ +use sqlx::PgPool; + +#[derive(Clone)] +pub struct AppState { + pub db: PgPool, +}