diff options
-rw-r--r-- | Cargo.lock | 332 | ||||
-rw-r--r-- | Cargo.toml | 14 | ||||
-rw-r--r-- | build.rs | 2 | ||||
-rw-r--r-- | fixtures/users.sql | 9 | ||||
-rw-r--r-- | migrations/20240321225523_init.up.sql | 12 | ||||
-rw-r--r-- | src/config.rs | 31 | ||||
-rw-r--r-- | src/error.rs | 75 | ||||
-rw-r--r-- | src/lib.rs | 2 | ||||
-rw-r--r-- | src/main.rs | 48 | ||||
-rw-r--r-- | src/model.rs | 55 | ||||
-rw-r--r-- | src/routes.rs | 167 | ||||
-rw-r--r-- | src/state.rs | 15 |
12 files changed, 294 insertions, 468 deletions
@@ -46,27 +46,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" [[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - -[[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.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247" - -[[package]] name = "argon2" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -99,12 +78,6 @@ dependencies = [ ] [[package]] -name = "auto-future" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" - -[[package]] name = "autocfg" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -120,7 +93,7 @@ dependencies = [ "axum-core", "bytes", "futures-util", - "http 1.1.0", + "http", "http-body", "http-body-util", "hyper", @@ -153,7 +126,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 1.1.0", + "http", "http-body", "http-body-util", "mime", @@ -177,7 +150,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.1.0", + "http", "http-body", "http-body-util", "mime", @@ -203,35 +176,6 @@ dependencies = [ ] [[package]] -name = "axum-test" -version = "14.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673f937bbc7eaadff359e2797c0de2c20e02c9db5bd6f2aed457c7ae93559b9b" -dependencies = [ - "anyhow", - "async-trait", - "auto-future", - "axum", - "bytes", - "cookie", - "http 1.1.0", - "http-body-util", - "hyper", - "hyper-util", - "mime", - "pretty_assertions", - "reserve-port", - "rust-multipart-rfc7578_2", - "serde", - "serde_json", - "serde_urlencoded", - "smallvec", - "tokio", - "tower", - "url", -] - -[[package]] name = "backtrace" version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -292,12 +236,6 @@ dependencies = [ ] [[package]] -name = "bumpalo" -version = "3.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" - -[[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -322,43 +260,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] -name = "chrono" -version = "0.4.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf5903dcbc0a39312feb77df2ff4c76387d591b9fc7b04a238dcf8bb62639a" -dependencies = [ - "android-tzdata", - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-targets 0.52.4", -] - -[[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] -name = "cookie" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cd91cf61412820176e137621345ee43b3f4423e589e7ae4e50d601d93e35ef8" -dependencies = [ - "time", - "version_check", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" - -[[package]] name = "cpufeatures" version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -425,15 +332,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", + "serde", ] [[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - -[[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -641,7 +543,7 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http 1.1.0", + "http", "indexmap", "slab", "tokio", @@ -718,17 +620,6 @@ dependencies = [ [[package]] name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" @@ -745,7 +636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" dependencies = [ "bytes", - "http 1.1.0", + "http", ] [[package]] @@ -756,7 +647,7 @@ checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d" dependencies = [ "bytes", "futures-core", - "http 1.1.0", + "http", "http-body", "pin-project-lite", ] @@ -783,7 +674,7 @@ dependencies = [ "futures-channel", "futures-util", "h2", - "http 1.1.0", + "http", "http-body", "httparse", "httpdate", @@ -791,7 +682,6 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", - "want", ] [[package]] @@ -801,40 +691,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa" dependencies = [ "bytes", - "futures-channel", "futures-util", - "http 1.1.0", + "http", "http-body", "hyper", "pin-project-lite", "socket2", "tokio", - "tower", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.60" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "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]] @@ -873,15 +736,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" [[package]] -name = "js-sys" -version = "0.3.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" -dependencies = [ - "wasm-bindgen", -] - -[[package]] name = "lazy_static" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -973,16 +827,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "mime_guess" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" -dependencies = [ - "mime", - "unicase", -] - -[[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1168,6 +1012,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] +name = "pgtemp" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fbb73566e38f90abdd281c5e6c0b99152a0c41d409fb35c985ffe02a3f39bc" +dependencies = [ + "libc", + "tempfile", + "tokio", + "url", +] + +[[package]] name = "pin-project" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1239,16 +1095,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] -name = "pretty_assertions" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" -dependencies = [ - "diff", - "yansi", -] - -[[package]] name = "proc-macro2" version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1350,16 +1196,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" [[package]] -name = "reserve-port" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9838134a2bfaa8e1f40738fcc972ac799de6e0e06b5157acb95fc2b05a0ea283" -dependencies = [ - "lazy_static", - "thiserror", -] - -[[package]] name = "rsa" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1380,22 +1216,6 @@ dependencies = [ ] [[package]] -name = "rust-multipart-rfc7578_2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03b748410c0afdef2ebbe3685a6a862e2ee937127cdaae623336a459451c8d57" -dependencies = [ - "bytes", - "futures-core", - "futures-util", - "http 0.2.12", - "mime", - "mime_guess", - "rand", - "thiserror", -] - -[[package]] name = "rustc-demangle" version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1632,7 +1452,6 @@ dependencies = [ "atoi", "byteorder", "bytes", - "chrono", "crc", "crossbeam-queue", "either", @@ -1656,6 +1475,7 @@ dependencies = [ "smallvec", "sqlformat", "thiserror", + "time", "tokio", "tokio-stream", "tracing", @@ -1713,7 +1533,6 @@ dependencies = [ "bitflags 2.5.0", "byteorder", "bytes", - "chrono", "crc", "digest", "dotenvy", @@ -1741,6 +1560,7 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror", + "time", "tracing", "uuid", "whoami", @@ -1756,7 +1576,6 @@ dependencies = [ "base64", "bitflags 2.5.0", "byteorder", - "chrono", "crc", "dotenvy", "etcetera", @@ -1781,6 +1600,7 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror", + "time", "tracing", "uuid", "whoami", @@ -1793,7 +1613,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa" dependencies = [ "atoi", - "chrono", "flume", "futures-channel", "futures-core", @@ -1805,6 +1624,7 @@ dependencies = [ "percent-encoding", "serde", "sqlx-core", + "time", "tracing", "url", "urlencoding", @@ -1955,6 +1775,7 @@ dependencies = [ "libc", "mio", "num_cpus", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -2089,27 +1910,12 @@ dependencies = [ ] [[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.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] -name = "unicase" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" -dependencies = [ - "version_check", -] - -[[package]] name = "unicode-bidi" version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2146,18 +1952,20 @@ checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" name = "unnamed-server" version = "0.1.0" dependencies = [ - "anyhow", "argon2", "axum", "axum-extra", - "axum-test", - "chrono", "dotenvy", + "http-body-util", + "mime", + "pgtemp", "serde", "serde_json", "sqlx", "thiserror", + "time", "tokio", + "tower", "tracing", "tracing-subscriber", "uuid", @@ -2208,15 +2016,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" [[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.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2229,60 +2028,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] -name = "wasm-bindgen" -version = "0.2.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" -dependencies = [ - "cfg-if", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" -dependencies = [ - "bumpalo", - "log", - "once_cell", - "proc-macro2", - "quote", - "syn 2.0.52", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.52", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" - -[[package]] name = "whoami" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2315,15 +2060,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "windows-core" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" -dependencies = [ - "windows-targets 0.52.4", -] - -[[package]] name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2456,12 +2192,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" [[package]] -name = "yansi" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" - -[[package]] name = "zerocopy" version = "0.7.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -6,18 +6,22 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -anyhow = "1.0.81" -argon2 = "0.5.3" +argon2 = { version = "0.5.3", features = ["std"] } axum = "0.7.4" axum-extra = { version = "0.9.2", features = ["typed-routing"] } -axum-test = "14.4.0" -chrono = { version = "0.4.35", features = ["serde"] } dotenvy = "0.15.7" serde = { version = "1.0.197", features = ["derive"] } serde_json = "1.0.114" -sqlx = { version = "0.7.3", features = ["postgres", "runtime-tokio", "uuid", "chrono"] } +sqlx = { version = "0.7.3", features = ["postgres", "runtime-tokio", "uuid", "time"] } thiserror = "1.0.58" +time = { version = "0.3.34", features = ["serde"] } tokio = { version = "1.36.0", features = ["macros", "rt-multi-thread", "signal"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } uuid = { version = "1.8.0", features = ["serde"] } + +[dev-dependencies] +pgtemp = "0.2.1" +tower = { version = "0.4.13", features = ["util"] } +mime = "0.3.17" +http-body-util = "0.1.1" @@ -2,4 +2,4 @@ fn main() { // trigger recompilation when a new migration is added println!("cargo:rerun-if-changed=migrations"); -}
\ No newline at end of file +} diff --git a/fixtures/users.sql b/fixtures/users.sql new file mode 100644 index 0000000..fa47f61 --- /dev/null +++ b/fixtures/users.sql @@ -0,0 +1,9 @@ +INSERT INTO users ( + name, + email, + password +) VALUES( + 'Arthur Dent', + 'adent@earth.sol', + 'solongandthanksforallthefish' +); diff --git a/migrations/20240321225523_init.up.sql b/migrations/20240321225523_init.up.sql index f3ee628..a744b99 100644 --- a/migrations/20240321225523_init.up.sql +++ b/migrations/20240321225523_init.up.sql @@ -1,5 +1,3 @@ --- Add up migration script here - CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE TABLE users ( @@ -16,13 +14,3 @@ CREATE TABLE users ( ); CREATE INDEX users_email_idx ON users (email); - -INSERT INTO users ( - name, - email, - password -) VALUES( - 'Arthur Dent', - 'adent@hitchhikers.com', - '42' -); diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index dc132b8..0000000 --- a/src/config.rs +++ /dev/null @@ -1,31 +0,0 @@ -#[derive(Debug, Default, Clone)] -pub struct Config { - pub database_url: String, - pub jwt_secret: String, - pub jwt_expires_in: String, - pub jwt_maxage: i32, -} - -impl Config { - pub fn init() -> Config { - let mut config = Config::default(); - - if let Ok(database_url) = std::env::var("DATABASE_URL") { - config.database_url = database_url; - }; - - if let Ok(jwt_secret) = std::env::var("JWT_SECRET") { - config.jwt_secret = jwt_secret; - }; - - if let Ok(jwt_expires_in) = std::env::var("JWT_EXPIRED_IN") { - config.jwt_expires_in = jwt_expires_in; - }; - - if let Ok(jwt_maxage) = std::env::var("JWT_MAXAGE") { - config.jwt_maxage = jwt_maxage.parse::<i32>().unwrap(); - }; - - config - } -} diff --git a/src/error.rs b/src/error.rs index a5b48ff..54075da 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,46 +1,69 @@ +use axum::{http::StatusCode, Json}; +use serde_json::json; + pub type Result<T, E = Error> = std::result::Result<T, E>; #[derive(thiserror::Error, Debug)] pub enum Error { - #[error(transparent)] + #[error("IO error: {0}")] IO(#[from] std::io::Error), - #[error(transparent)] - TaskJoin(#[from] tokio::task::JoinError), + #[error("Env variable error: {0}")] + Env(#[from] dotenvy::Error), - #[error(transparent)] + #[error("Axum error: {0}")] Axum(#[from] axum::Error), - #[error(transparent)] + #[error("Http error: {0}")] + Http(#[from] axum::http::Error), + + #[error("Json error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Database error: {0}")] Sqlx(#[from] sqlx::Error), - #[error(transparent)] + #[error("Migration error: {0}")] Migration(#[from] sqlx::migrate::MigrateError), - #[error("User not found: {0}")] - UserNotFound(uuid::Uuid), + #[error("Failed to hash password: {0}")] + PasswordHash(#[from] argon2::password_hash::Error), + + #[error("User not found")] + UserNotFound, + + #[error("User with that email already exists")] + EmailExists, + + #[error("Email is invalid")] + EmailInvalid, + + #[error("Password is invalid")] + PasswordInvalid, + + #[error("{0}")] + Other(String), +} + +impl From<&Error> for StatusCode { + fn from(value: &Error) -> Self { + match value { + Error::UserNotFound => StatusCode::NOT_FOUND, + Error::EmailExists => StatusCode::CONFLICT, + Error::EmailInvalid | Error::PasswordInvalid => StatusCode::UNPROCESSABLE_ENTITY, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + } } impl axum::response::IntoResponse for Error { fn into_response(self) -> axum::response::Response { - use axum::{http::StatusCode, Json}; - use serde_json::json; - - match self { - Error::UserNotFound(uuid) => ( - StatusCode::BAD_REQUEST, - Json(json!({ - "status": "fail", - "message": uuid, - })), - ), - err => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ - "error": err.to_string(), - })), - ), - } + // TODO: implement [rfc7807](https://www.rfc-editor.org/rfc/rfc7807.html) + + Json(json!({ + "status": StatusCode::from(&self).to_string(), + "detail": self.to_string(), + })) .into_response() } } @@ -2,6 +2,6 @@ pub use error::{Error, Result}; pub use routes::router; pub mod error; +pub mod model; pub mod routes; pub mod state; -pub mod model; diff --git a/src/main.rs b/src/main.rs index f2b81ae..1edf738 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,12 @@ +use std::sync::Arc; + use tokio::net::TcpListener; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use unnamed_server::state::AppState; - -use crate::config::Config; - -mod config; +use unnamed_server::{state::AppState, Error}; #[tokio::main] #[tracing::instrument] -async fn main() -> Result<(), unnamed_server::Error> { +async fn main() -> Result<(), Error> { tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() @@ -18,43 +16,15 @@ async fn main() -> Result<(), unnamed_server::Error> { .init(); let _ = dotenvy::dotenv(); + let listen_addr = std::env::var("ADDRESS").unwrap_or("127.0.0.1:30000".to_string()); + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL is not set"); - let config = Config::init(); - - let state = AppState::new(config.database_url).await?; - + let state = Arc::new(AppState::init(&database_url).await?); let app = unnamed_server::router(state); - let listener = TcpListener::bind("127.0.0.1:30000").await?; + let listener = TcpListener::bind(listen_addr).await?; tracing::info!("Server listening on http://{}", listener.local_addr()?); - axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal()) - .await - .map_err(From::from) -} - -async fn shutdown_signal() { - let ctrl_c = async { - tokio::signal::ctrl_c() - .await - .expect("failed to install Ctrl+C handler"); - }; - - #[cfg(unix)] - let terminate = async { - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .expect("failed to install signal handler") - .recv() - .await; - }; - - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - - tokio::select! { - _ = ctrl_c => {}, - _ = terminate => {}, - } + axum::serve(listener, app).await.map_err(From::from) } diff --git a/src/model.rs b/src/model.rs index 9c1bfe6..5f6111e 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,28 +1,16 @@ -use chrono::prelude::*; use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; -#[allow(non_snake_case)] -#[derive(Debug, Deserialize, sqlx::FromRow, Serialize, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +#[serde(rename_all = "camelCase")] pub struct User { pub id: uuid::Uuid, pub name: String, pub email: String, + #[serde(default, skip_serializing)] pub password: String, - #[serde(rename = "createdAt")] - pub created_at: Option<DateTime<Utc>>, - #[serde(rename = "updatedAt")] - pub updated_at: Option<DateTime<Utc>>, -} - -impl User { - pub fn into_query_response(self) -> axum::Json<serde_json::Value> { - axum::Json(serde_json::json!({ - "status": "success", - "data": serde_json::json!({ - "user": self - }) - })) - } + pub created_at: Option<OffsetDateTime>, + pub updated_at: Option<OffsetDateTime>, } #[derive(Debug, Serialize, Deserialize)] @@ -32,15 +20,38 @@ pub struct TokenClaims { pub exp: usize, } -#[derive(Debug, Deserialize)] -pub struct RegisterUserSchema { +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RegisterSchema { pub name: String, pub email: String, + #[serde(default, skip_serializing)] pub password: String, } -#[derive(Debug, Deserialize)] -pub struct LoginUserSchema { +#[derive(Debug, Serialize, Deserialize)] +pub struct LoginSchema { pub email: String, + #[serde(default, skip_serializing)] pub password: String, } + +macro_rules! impl_from_superset { + ($from:tt, $to:ty, $($field:tt)*) => { + impl From<$from> for $to { + fn from(value: $from) -> Self { + let $from { + $($field)*, + .. + } = value; + + Self { + $($field)*, + } + } + } + }; +} + +impl_from_superset!(User, RegisterSchema, name, email, password); +impl_from_superset!(User, LoginSchema, email, password); +impl_from_superset!(RegisterSchema, LoginSchema, email, password); diff --git a/src/routes.rs b/src/routes.rs index 0a81317..0bf34b2 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,22 +1,33 @@ use std::sync::Arc; +use argon2::{ + password_hash::{rand_core::OsRng, SaltString}, + Argon2, PasswordHasher, +}; use axum::{ extract::State, http::{StatusCode, Uri}, + response::IntoResponse, Json, }; use axum_extra::routing::{RouterExt, TypedPath}; use serde::Deserialize; -use crate::{model::User, state::AppState, Error}; +use crate::{ + model::{RegisterSchema, User}, + state::AppState, + Error, +}; -pub fn router(state: AppState) -> axum::Router { +#[tracing::instrument] +pub fn router(state: Arc<AppState>) -> axum::Router { axum::Router::new() // .route("/api/user", get(get_user)) .typed_get(HealthCheck::get) - .typed_get(UserId::get) + .typed_get(UserUuid::get) + .typed_post(Register::post) .fallback(fallback) - .with_state(Arc::new(state)) + .with_state(state) } #[derive(Debug, Deserialize, TypedPath)] @@ -24,7 +35,8 @@ pub fn router(state: AppState) -> axum::Router { pub struct HealthCheck; impl HealthCheck { - pub async fn get(self) -> Json<serde_json::Value> { + #[tracing::instrument] + pub async fn get(self) -> impl IntoResponse { const MESSAGE: &str = "Unnamed server"; let json_response = serde_json::json!({ @@ -38,26 +50,62 @@ impl HealthCheck { #[derive(Debug, Deserialize, TypedPath)] #[typed_path("/api/user/:uuid")] -pub struct UserId { +pub struct UserUuid { pub uuid: uuid::Uuid, } -impl UserId { - /// Get a user via their `id` - #[tracing::instrument(ret, skip(data))] - pub async fn get( - self, - State(data): State<Arc<AppState>>, - ) -> Result<Json<serde_json::Value>, Error> { +impl UserUuid { + /// Get a user with a specific `uuid` + #[tracing::instrument] + pub async fn get(self, State(state): State<Arc<AppState>>) -> impl IntoResponse { sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", self.uuid) - .fetch_optional(&data.db_pool) + .fetch_optional(&state.pool) .await? - .ok_or_else(|| Error::UserNotFound(self.uuid)) - .map(User::into_query_response) + .ok_or_else(|| Error::UserNotFound) + .map(Json) + } +} + +#[derive(Debug, Deserialize, TypedPath)] +#[typed_path("/api/user/register")] +pub struct Register; + +impl Register { + #[tracing::instrument(skip(register_schema))] + pub async fn post( + self, + State(state): State<Arc<AppState>>, + Json(register_schema): Json<RegisterSchema>, + ) -> impl IntoResponse { + let exists: Option<bool> = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)") + .bind(register_schema.email.to_ascii_lowercase()) + .fetch_one(&state.pool) + .await?; + + if exists.is_some_and(|b| b) { + return Err(Error::EmailExists); + } + + let salt = SaltString::generate(&mut OsRng); + let hashed_password = + Argon2::default().hash_password(register_schema.password.as_bytes(), &salt)?; + + let user = sqlx::query_as!( + User, + "INSERT INTO users (name,email,password) VALUES ($1, $2, $3) RETURNING *", + register_schema.name, + register_schema.email.to_ascii_lowercase(), + hashed_password.to_string() + ) + .fetch_one(&state.pool) + .await?; + + Ok((StatusCode::CREATED, Json(user))) } } -pub async fn fallback(uri: Uri) -> (StatusCode, String) { +pub async fn fallback(uri: Uri) -> impl IntoResponse { (StatusCode::NOT_FOUND, format!("Route not found: {uri}")) } @@ -65,15 +113,88 @@ pub async fn fallback(uri: Uri) -> (StatusCode, String) { mod tests { use super::*; - use axum_test::TestServer; + use axum::{ + body::Body, + http::{header, Request, StatusCode}, + }; + use http_body_util::BodyExt; + use sqlx::PgPool; + use tower::ServiceExt; + + #[sqlx::test] + async fn test_fallback(pool: PgPool) -> Result<(), Error> { + let state = Arc::new(AppState { pool }); + let router = router(state.clone()); + + let response = router + .oneshot( + Request::builder() + .uri("/does-not-exist") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(StatusCode::NOT_FOUND, response.status()); - #[tokio::test] - async fn test_fallback() -> Result<(), Box<dyn std::error::Error>> { - let server = TestServer::new(axum::Router::new().fallback(fallback))?; + Ok(()) + } + + #[sqlx::test(fixtures(path = "../fixtures", scripts("users")))] + async fn test_user(pool: PgPool) -> Result<(), Error> { + let state = Arc::new(AppState { pool }); + let router = router(state.clone()); + + let user = sqlx::query_as!(User, "SELECT * FROM users LIMIT 1") + .fetch_one(&state.pool) + .await?; - let response = server.get("/fallback").await; + let response = router + .oneshot( + Request::builder() + .uri(format!("/api/user/{}", user.id)) + .body(Body::empty())?, + ) + .await + .unwrap(); + + assert_eq!(StatusCode::OK, response.status()); + + Ok(()) + } - assert_eq!(StatusCode::NOT_FOUND, response.status_code()); + #[sqlx::test] + async fn test_user_register(pool: PgPool) -> Result<(), Error> { + let state = Arc::new(AppState { pool }); + let router = router(state.clone()); + + let register_user = RegisterSchema { + name: "Ford Prefect".to_string(), + email: "fprefect@heartofgold.galaxy".to_string(), + password: "42".to_string(), + }; + + let response = router + .oneshot( + Request::builder() + .uri("/api/user/register") + .method("POST") + .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::from( + serde_json::to_vec(&serde_json::json!(register_user)).unwrap(), + ))?, + ) + .await + .unwrap(); + + assert_eq!(StatusCode::CREATED, response.status()); + + let body_bytes = response.into_body().collect().await?.to_bytes(); + let user: User = serde_json::from_slice(&body_bytes)?; + + assert_eq!(register_user.name, user.name); + assert_eq!(register_user.email, user.email); Ok(()) } diff --git a/src/state.rs b/src/state.rs index efe2192..614688b 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,18 +1,19 @@ use sqlx::{postgres::PgPoolOptions, Pool, Postgres}; -use crate::Error; - +#[derive(Debug)] pub struct AppState { - pub db_pool: Pool<Postgres>, + pub pool: Pool<Postgres>, } impl AppState { - pub async fn new<S: AsRef<str>>(db_url: S) -> Result<Self, Error> { - let db_pool = PgPoolOptions::new() + pub async fn init(database_uri: &str) -> Result<Self, sqlx::Error> { + let pool = PgPoolOptions::new() .max_connections(10) - .connect(db_url.as_ref()) + .connect(database_uri) .await?; - Ok(Self { db_pool }) + sqlx::migrate!().run(&pool).await?; + + Ok(Self { pool }) } } |