diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml new file mode 100644 index 0000000..6d58a4e --- /dev/null +++ b/.forgejo/workflows/ci.yaml @@ -0,0 +1,79 @@ +on: + push: + workflow_dispatch: + +env: + ATTIC_ENDPOINT: ${{ vars.ATTIC_ENDPOINT }} + ATTIC_CACHE: ${{ vars.ATTIC_CACHE }} + +jobs: + build: + runs-on: nix + steps: + - uses: actions/checkout@v4 + - name: Configure Attic + shell: bash + run: | + attic login --set-default central "$ATTIC_ENDPOINT" "$ATTIC_TOKEN" + attic use "$ATTIC_CACHE" + env: + ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} + - name: Build + run: nix-fast-build --skip-cached --no-nom --attic-cache $ATTIC_CACHE --flake .#packages + - name: Process results + run: | + mkdir images + for file in result-*dockerImage*; do + if [ ! -f "$file" ]; then + continue + fi + info=$(echo "$file" | sd '^result-([a-z0-9_-]+)\.([a-zA-Z]+)(?:-cross-([a-z0-9_-]+))?$' '$1\x1F$2\x1F$3') + IFS=$'\x1F' read -r hostArch name crossArch <<< "$info" + arch=${crossArch:-$hostArch} + containerArch=$(arch=$arch nix eval --raw --impure --expr '(import { system = builtins.getEnv "arch";}).go.GOARCH') + mv $file images/image-$containerArch.tar.gz + done + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: docker-images + path: images/ + - name: Login to Forgejo Container Registry + if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' + shell: bash + run: podman login git.dirksys.ovh --username $USERNAME --password $PASSWORD + env: + USERNAME: ${{ github.actor }} + PASSWORD: ${{ secrets.GITHUB_TOKEN }} + - name: Push docker images + if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' + shell: bash + run: | + manifest_id=$(podman manifest create git.dirksys.ovh/dirk/bankserver:latest) + sleep 1 + for file in images/*; do + echo "Loading $file" + podman image load < $file + image_id=$(podman image ls --format '{{.ID}}' | head -n 2 | tail -1) + architecture=$(podman image inspect $image_id | jq -r ".[0].Architecture") + tag=$(podman image ls --format "{{.Tag}}" | head -n 2 | tail -1) + tag="git.dirksys.ovh/dirk/bankserver:$tag-$architecture" + podman image untag $image_id + echo "Adding $architecture image to manifest" + podman manifest add $manifest_id $image_id + done + echo "Pushing manifest" + podman manifest push git.dirksys.ovh/dirk/bankserver:latest + - name: Notify server + if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' + env: + DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + eval $(ssh-agent -s) + ssh-add <(echo "$DEPLOY_KEY") + mkdir -p ~/.ssh + echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config + ssh $DEPLOY_USER@$DEPLOY_HOST "systemctl try-restart podman-deployment-bankserver-staging" + sleep 2 diff --git a/.gitignore b/.gitignore index ea8c4bf..dad01e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ /target +/result-* +/result +/schemas diff --git a/.helix/ignore b/.helix/ignore new file mode 100644 index 0000000..703991b --- /dev/null +++ b/.helix/ignore @@ -0,0 +1 @@ +/schemas diff --git a/Cargo.lock b/Cargo.lock index 71a0879..9f37fd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,33 @@ dependencies = [ "memchr", ] +[[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 = "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 = "async-trait" version = "0.1.86" @@ -117,15 +144,24 @@ name = "bankserver" version = "0.1.0" dependencies = [ "axum", + "chrono", + "config", + "dbmigrator", "deadpool", "deadpool-postgres", + "futures-util", + "garde", "jsonwebtoken", + "password-auth", + "schemars", "serde", "serde_json", "tokio", "tokio-postgres", "tracing", + "tracing-error", "tracing-subscriber", + "uuid", ] [[package]] @@ -134,12 +170,27 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "bitflags" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" +[[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" @@ -168,10 +219,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" [[package]] -name = "cc" -version = "1.2.15" +name = "castaway" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c736e259eea577f443d5c86c304f9f4ae0295c43f3ba05c21f1d66b5f06001af" +checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" dependencies = [ "shlex", ] @@ -182,6 +242,53 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "config" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf9dc8d4ef88e27a8cb23e85cb116403dedd57f7971964dc4b18ccead548901" +dependencies = [ + "pathdiff", + "serde", + "winnow", +] + +[[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" @@ -201,6 +308,44 @@ dependencies = [ "typenum", ] +[[package]] +name = "dbmigrator" +version = "0.4.4-alpha" +source = "git+https://github.com/DSeeLP/dbmigrator.git?branch=macros#8075e58f28110cd8b2db97048ae0eb1f87acf54a" +dependencies = [ + "async-trait", + "dbmigrator_core", + "dbmigrator_macros", + "thiserror", + "time", + "tokio", + "tokio-postgres", +] + +[[package]] +name = "dbmigrator_core" +version = "0.4.3-alpha" +source = "git+https://github.com/DSeeLP/dbmigrator.git?branch=macros#8075e58f28110cd8b2db97048ae0eb1f87acf54a" +dependencies = [ + "regex", + "sha2", + "thiserror", + "version-compare", + "walkdir", +] + +[[package]] +name = "dbmigrator_macros" +version = "0.4.3-alpha" +source = "git+https://github.com/DSeeLP/dbmigrator.git?branch=macros#8075e58f28110cd8b2db97048ae0eb1f87acf54a" +dependencies = [ + "dbmigrator_core", + "proc-macro2", + "quote", + "regex", + "syn", +] + [[package]] name = "deadpool" version = "0.12.2" @@ -237,6 +382,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -248,6 +402,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "dyn-clone" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "feeef44e73baff3a26d371801df019877a9866a8c493d315ab00177843314f35" + [[package]] name = "fallible-iterator" version = "0.2.0" @@ -323,6 +483,32 @@ dependencies = [ "slab", ] +[[package]] +name = "garde" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a989bd2fd12136080f7825ff410d9239ce84a2a639487fc9d924ee42e2fb84f" +dependencies = [ + "compact_str", + "garde_derive", + "once_cell", + "regex", + "serde", + "smallvec", +] + +[[package]] +name = "garde_derive" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7f0545bbbba0a37d4d445890fa5759814e0716f02417b39f6fab292193df68" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -460,6 +646,29 @@ dependencies = [ "tower-service", ] +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +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]] name = "itoa" version = "1.0.14" @@ -571,7 +780,7 @@ checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -584,6 +793,21 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.16.0" @@ -638,6 +862,35 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "password-auth" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2a4764cc1f8d961d802af27193c6f4f0124bd0e76e8393cf818e18880f0524" +dependencies = [ + "argon2", + "getrandom 0.2.15", + "password-hash", + "rand_core 0.6.4", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.1" @@ -699,11 +952,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613283563cd90e1dfc3518d548caee47e0e725455ed619881f5cf21f36de4b48" dependencies = [ "bytes", + "chrono", "fallible-iterator", "postgres-protocol", + "time", "uuid", ] +[[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.20" @@ -738,8 +999,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" dependencies = [ "rand_chacha", - "rand_core", - "zerocopy 0.8.20", + "rand_core 0.9.2", + "zerocopy 0.8.21", ] [[package]] @@ -749,7 +1010,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.15", ] [[package]] @@ -759,7 +1029,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a509b1a2ffbe92afab0e55c8fd99dea1c280e8171bd2d88682bb20bc41cbc2c" dependencies = [ "getrandom 0.3.1", - "zerocopy 0.8.20", + "zerocopy 0.8.21", ] [[package]] @@ -771,6 +1041,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf0a6f84d5f1d581da8b41b47ec8600871962f2a528115b542b362d4b744931" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc303e793d3734489387d205e9b186fac9c6cfacedd98cbb2e8a5943595f3e6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.11.1" @@ -826,7 +1116,7 @@ dependencies = [ "getrandom 0.2.15", "libc", "untrusted", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -847,6 +1137,42 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" +[[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 = "schemars" +version = "1.0.0-alpha.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ef2a6523400a2228db974a8ddc9e1d3deaa04f51bddd7832ef8d7e531bafcc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "schemars_derive" +version = "1.0.0-alpha.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6d4e1945a3c9e58edaa708449b026519f7f4197185e1b5dbc689615c1ad724d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -873,6 +1199,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.139" @@ -953,6 +1290,9 @@ name = "smallvec" version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -961,9 +1301,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.52.0", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stringprep" version = "0.1.5" @@ -998,6 +1344,26 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "thiserror" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.8" @@ -1008,6 +1374,37 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinyvec" version = "1.8.1" @@ -1037,7 +1434,7 @@ dependencies = [ "socket2", "tokio-macros", "tracing", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -1151,6 +1548,16 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + [[package]] name = "tracing-log" version = "0.2.0" @@ -1224,6 +1631,10 @@ name = "uuid" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0f540e3240398cce6128b64ba83fdbdd86129c16a3aa1a3a252efd66eb3d587" +dependencies = [ + "getrandom 0.3.1", + "serde", +] [[package]] name = "valuable" @@ -1231,12 +1642,28 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + [[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 = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1266,6 +1693,7 @@ checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", "once_cell", + "rustversion", "wasm-bindgen-macro", ] @@ -1352,12 +1780,36 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" 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", +] + +[[package]] +name = "windows-link" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" + [[package]] name = "windows-sys" version = "0.52.0" @@ -1367,6 +1819,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -1431,6 +1892,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7f4ea97f6f78012141bcdb6a216b2609f0979ada50b20ca5b52dde2eac2bb1" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen-rt" version = "0.33.0" @@ -1452,11 +1922,11 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.20" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dde3bb8c68a8f3f1ed4ac9221aad6b10cece3e60a8e2ea54a6a2dec806d0084c" +checksum = "dcf01143b2dd5d134f11f545cf9f1431b13b749695cb33bcce051e7568f99478" dependencies = [ - "zerocopy-derive 0.8.20", + "zerocopy-derive 0.8.21", ] [[package]] @@ -1472,9 +1942,9 @@ dependencies = [ [[package]] name = "zerocopy-derive" -version = "0.8.20" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eea57037071898bf96a6da35fd626f4f27e9cee3ead2a6c703cf09d472b2e700" +checksum = "712c8386f4f4299382c9abee219bee7084f78fb939d88b6840fcc1320d5f6da2" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 085387f..fde2797 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,14 +3,27 @@ name = "bankserver" version = "0.1.0" edition = "2024" +[features] +default = ["schemas"] +schemas = ["dep:schemars"] + [dependencies] axum = "0.8" +chrono = { version = "0.4.40", features = ["serde"] } +config = { version = "0.15.8", default-features = false } +dbmigrator = { git = "https://github.com/DSeeLP/dbmigrator.git", branch = "macros", version = "0.4.4-alpha", features = ["tokio-postgres"] } deadpool = "0.12" deadpool-postgres = { version = "0.14", features = ["serde"] } +futures-util = "0.3.31" +garde = { version = "0.22.0", features = ["serde", "derive", "regex", "pattern"] } jsonwebtoken = { version = "9.3", default-features = false } +password-auth = "1.0.0" +schemars = { version = "1.0.0-alpha.17", optional = true, features = ["chrono04", "uuid1"] } serde = { version = "1.0.218", features = ["derive"] } serde_json = "1.0.139" -tokio = { version = "1.43", features = ["tracing", "time", "sync", "net", "io-std", "io-util", "macros"] } -tokio-postgres = { version = "0.7.13", features = ["with-uuid-1"] } +tokio = { version = "1.43", features = ["tracing", "time", "sync", "net", "io-std", "io-util", "macros", "rt-multi-thread"] } +tokio-postgres = { version = "0.7.13", features = ["with-chrono-0_4", "with-uuid-1"] } tracing = "0.1.41" +tracing-error = "0.2.1" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +uuid = { version = "1.15.1", features = ["serde", "v7"] } diff --git a/flake.lock b/flake.lock index d52685b..1bda546 100644 --- a/flake.lock +++ b/flake.lock @@ -14,7 +14,28 @@ }, "root": { "inputs": { - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1740709839, + "narHash": "sha256-4dF++MXIXna/AwlZWDKr7bgUmY4xoEwvkF1GewjNrt0=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "b4270835bf43c6f80285adac6f66a26d83f0f277", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index a2373de..0d71015 100644 --- a/flake.nix +++ b/flake.nix @@ -3,25 +3,83 @@ inputs = { nixpkgs.url = "flake:nixpkgs"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; outputs = - { self, nixpkgs }: + { + self, + nixpkgs, + rust-overlay, + }: let forAllSystems = function: - nixpkgs.lib.genAttrs [ - "x86_64-linux" - "aarch64-linux" - ] (system: function nixpkgs.legacyPackages.${system}); + nixpkgs.lib.genAttrs + [ + "x86_64-linux" + "aarch64-linux" + ] + ( + system: + function ( + import nixpkgs { + inherit system; + overlays = [ + (import rust-overlay) + (_: final: overlay final) + ]; + } + ) + ); + overlay = + pkgs: + let + rustVersion = pkgs.pkgsBuildHost.rust-bin.stable.latest.minimal; + rustPlatform = pkgs.makeRustPlatform { + cargo = rustVersion; + rustc = rustVersion; + }; + bankserver = pkgs.callPackage ./package.nix { + inherit rustPlatform; + rev = self.shortRev or "dirty"; + }; + in + { + inherit bankserver; + bankserverDocker = pkgs.dockerTools.buildLayeredImage { + name = "bankserver"; + # tag = "latest"; + created = "now"; + contents = [ bankserver ]; + config = { + Env = [ "HOST=::" ]; + Entrypoint = [ "/bin/bankserver" ]; + ExposedPorts = { + "3845/tcp" = { }; + }; + }; + }; + }; in { packages = forAllSystems (pkgs: { - default = pkgs.callPackage ./package.nix { rev = self.shortRev or "dirty"; }; + default = pkgs.bankserver; + dockerImage = pkgs.bankserverDocker; + dockerImage-cross-aarch64-linux = pkgs.pkgsCross.aarch64-multiplatform.bankserverDocker; }); devShells = forAllSystems (pkgs: { default = pkgs.mkShell { - packages = with pkgs; [ hurl ]; + packages = with pkgs; [ + hurl + redocly + yq-go + just + ]; }; }); }; diff --git a/justfile b/justfile new file mode 100644 index 0000000..35bd190 --- /dev/null +++ b/justfile @@ -0,0 +1,12 @@ +[private] +default: + just --list + +dev: + just openapi + cargo run + +openapi: + yq eval-all -n 'load("openapi-def.yaml") *n load("schemas/schemas.json")' > openapi-temp.yaml + redocly bundle openapi-temp.yaml -o openapi.json + rm openapi-temp.yaml diff --git a/migrations/000000_baseline.sql b/migrations/000000_baseline.sql new file mode 100644 index 0000000..5cba077 --- /dev/null +++ b/migrations/000000_baseline.sql @@ -0,0 +1,25 @@ +create table users( + id uuid primary key, + name varchar(32) not null unique, + password varchar(128) +); + +create table accounts( + id uuid primary key, + "user" uuid not null references users(id), + name varchar(32) not null unique, + balance bigint not null default 0 constraint positive_balance check (balance >= 0) +); + +create table transactions( + "from" uuid not null references accounts(id), + "to" uuid not null references accounts(id), + amount bigint not null constraint positive_amount check (amount > 0), + timestamp timestamp without time zone not null default now(), + message text +); + +create index transactions_from on transactions ("from"); +create index transactions_to on transactions ("to"); + +create view transactions_with_user as select t.*, (select user from accounts where id = t.from) as from_user, (select user from accounts where id = t.to) as to_user from transactions t; diff --git a/openapi-def.yaml b/openapi-def.yaml new file mode 100644 index 0000000..5a847f5 --- /dev/null +++ b/openapi-def.yaml @@ -0,0 +1,302 @@ +# yaml-language-server: $schema=https://spec.openapis.org/oas/3.1/schema/2024-11-14 +openapi: 3.1.0 +info: + title: Bankserver + version: 0.0.1 +tags: + - name: Authentication + - name: Users + - name: Accounts + - name: Transactions +paths: + /api/login: + post: + operationId: login + tags: + - Authentication + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Credentials' + responses: + 200: + description: Login successful + content: + application/json: + schema: + $ref: '#/components/schemas/LoginSuccess' + default: + $ref: '#/components/responses/Default' + /api/register: + post: + operationId: register + tags: + - Authentication + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Credentials' + responses: + 201: + description: Registration successful + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterSuccess' + 409: + description: User already exists + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + example: + id: conflict + message: string + default: + $ref: '#/components/responses/Default' + + /api/users/{userId}: + get: + operationId: user-info + summary: User Info + parameters: + - $ref: '#/components/parameters/UserId' + tags: + - Users + security: + - bearer: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/UserInfo' + 401: + $ref: '#/components/responses/Unauthorized' + default: + $ref: '#/components/responses/Default' + /api/users/@me: + get: + operationId: self-get-info + summary: User Info + tags: + - Users + security: + - bearer: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/UserInfo' + 401: + $ref: '#/components/responses/Unauthorized' + default: + $ref: '#/components/responses/Default' + /api/users/@me/balance: + get: + operationId: self-get-balance + summary: Sum of all account balances + tags: + - Users + security: + - bearer: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/UserBalance' + 401: + $ref: '#/components/responses/Unauthorized' + default: + $ref: '#/components/responses/Default' + /api/users/@me/accounts: + get: + operationId: self-list-accounts + summary: User accounts + tags: + - Users + - Accounts + security: + - bearer: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/UserAccounts' + 401: + $ref: '#/components/responses/Unauthorized' + default: + $ref: '#/components/responses/Default' + /api/users/@me/transactions: + get: + operationId: self-transaction-history + summary: Transaction history + tags: + - Users + - Transactions + security: + - bearer: [] + parameters: + - $ref: '#/components/parameters/Direction' + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/TransactionHistory' + 401: + $ref: '#/components/responses/Unauthorized' + default: + $ref: '#/components/responses/Default' + /api/transactions: + post: + operationId: pay + summary: Make payment + tags: + - Transactions + security: + - bearer: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MakePayment' + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/Transaction' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + default: + $ref: '#/components/responses/Default' + /api/accounts: + get: + operationId: accounts-list-all + summary: List all accounts + tags: + - Accounts + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/ListAccounts' + default: + $ref: '#/components/responses/Default' + + /api/accounts/{accountId}/transactions: + get: + operationId: account-transaction-history + summary: Transaction history + tags: + - Accounts + - Transactions + security: + - bearer: [] + parameters: + - $ref: '#/components/parameters/AccountId' + - $ref: '#/components/parameters/Direction' + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/TransactionHistory' + 401: + $ref: '#/components/responses/Unauthorized' + default: + $ref: '#/components/responses/Default' + /api/users: + get: + operationId: users-list-all + summary: List all users + tags: + - Users + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/ListUsers' + default: + $ref: '#/components/responses/Default' +components: + parameters: + Direction: + name: direction + in: query + schema: + $ref: '#/components/schemas/Direction' + required: false + AccountId: + name: accountId + in: path + required: true + schema: + type: string + format: uuid + UserId: + name: userId + in: path + required: true + schema: + type: string + format: uuid + securitySchemes: + bearer: + type: http + scheme: bearer + bearerFormat: JWT + responses: + InternalServerEror: + description: Internal Server Error + Default: + description: Other Errors + Unauthorized: + description: Access token is missing or invalid + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + examples: + missing_header: + value: + id: auth.missing_header + message: string + invalid_jwt: + value: + id: auth.jwt.invalid + message: string + UnprocessableEntity: + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + examples: + malformed_body: + value: + id: malformed_body + message: string diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/openapi.json @@ -0,0 +1 @@ + diff --git a/package.nix b/package.nix index a995caa..7eff101 100644 --- a/package.nix +++ b/package.nix @@ -1,12 +1,41 @@ { + lib, rustPlatform, + stdenv, + redocly, + yq-go, + targetPlatform, rev ? "dirty", }: rustPlatform.buildRustPackage { pname = "bankingserver"; version = "unstable-${rev}"; - src = ./.; + src = lib.cleanSource ./.; + nativeBuildInputs = [ + redocly + yq-go + ]; + + preBuild = '' + cargo test --features schemas --target ${stdenv.buildPlatform.rust.rustcTarget} + + yq eval-all -n 'load("openapi-def.yaml") *n load("schemas/schemas.json")' > openapi-temp.yaml + redocly bundle openapi-temp.yaml -o openapi.json + ''; + + buildType = "debug"; + + useFetchCargoVendor = false; cargoLock.lockFile = ./Cargo.lock; + cargoLock.outputHashes = { + "dbmigrator-0.4.4-alpha" = "sha256-Nwxw74IyZeZ9dODb+aneQmuQe0grO+g45B3zv1XaihE="; + }; + + CARGO_BUILD_TARGET = targetPlatform.config; + + TARGET_CC = "${stdenv.cc}/bin/${stdenv.cc.targetPrefix}cc"; + + doCheck = false; } diff --git a/src/api/account.rs b/src/api/account.rs new file mode 100644 index 0000000..941753b --- /dev/null +++ b/src/api/account.rs @@ -0,0 +1,26 @@ +use std::sync::Arc; + +use axum::{Router, extract::Path, routing::get}; +use serde::Serialize; +use uuid::Uuid; + +use crate::model::AccountInfo; + +use super::{AppState, EState, State, auth::Auth, make_schemas}; + +pub(super) fn router() -> Router> { + Router::new() + .route("/{id}", get(account_info)) + .route("/", get(list_accounts)) +} + +make_schemas!((); (ListAccounts)); + +#[derive(Serialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct ListAccounts { + accounts: Vec, +} + +pub async fn account_info(EState(state): State, _: Auth, Path(id): Path) {} +pub async fn list_accounts(EState(state): State, _: Auth) {} diff --git a/src/api/auth.rs b/src/api/auth.rs new file mode 100644 index 0000000..7fb5672 --- /dev/null +++ b/src/api/auth.rs @@ -0,0 +1,216 @@ +use std::{ + borrow::Cow, + sync::Arc, + time::{Duration, SystemTime}, +}; + +use axum::{ + Router, + extract::FromRequestParts, + http::{StatusCode, request::Parts}, + routing::post, +}; +use garde::Validate; +use jsonwebtoken::{Algorithm, Header, Validation}; +use password_auth::verify_password; +use serde::{Deserialize, Serialize}; +use tracing::instrument; +use uuid::Uuid; + +use crate::{ + api::{ApiError, InnerError}, + model::User, +}; + +use super::{AppState, EState, Error, Json, State, make_schemas}; + +pub(super) fn router() -> Router> { + Router::new() + .route("/register", post(register)) + .route("/login", post(login)) +} + +make_schemas!((Credentials); (RegisterSuccess, LoginSuccess)); + +#[derive(Deserialize, Validate)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct Credentials { + #[garde(length(min = 3, max = 32), alphanumeric, pattern("^[a-z0-9_-]+$"))] + pub name: String, + #[garde(length(min = 8, max = 96))] + pub password: String, +} + +impl std::fmt::Debug for Credentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Credentials") + .field("name", &self.name) + .field("password", &"...") + .finish() + } +} + +#[instrument(skip(state))] +async fn register( + EState(state): State, + Json(credentials): Json, +) -> Result<(StatusCode, Json), Error> { + let mut conn = state.conn().await?; + let id = User::create(&mut conn, &credentials.name, &credentials.password).await?; + let token = Claims::new(id).encode(&state.encoding_key).unwrap(); + Ok((StatusCode::CREATED, Json(RegisterSuccess { token }))) +} + +#[instrument(skip(state))] +async fn login( + EState(state): State, + Json(credentials): Json, +) -> Result, Error> { + let conn = state.conn().await?; + let Some((hash, info)) = + User::get_password_and_info_by_username(&conn, &credentials.name).await? + else { + return Err(invalid_username_or_password().into()); + }; + verify_password(credentials.password, &hash).map_err(|err| match err { + password_auth::VerifyError::Parse(parse_error) => { + Error::new(InnerError::PHCParse(parse_error)) + } + password_auth::VerifyError::PasswordInvalid => invalid_username_or_password().into(), + })?; + let jwt = Claims::new(info.id).encode(&state.encoding_key).unwrap(); + Ok(Json(LoginSuccess { token: jwt })) +} + +#[derive(Serialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct LoginSuccess { + token: String, +} + +#[derive(Serialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct RegisterSuccess { + token: String, +} + +#[derive(Serialize, Deserialize)] +struct Claims<'a> { + #[serde(rename = "iss")] + issuer: Cow<'a, str>, + #[serde(rename = "sub")] + subject: Uuid, + #[serde(rename = "iat")] + issued_at: u64, + #[serde(rename = "exp")] + expires: u64, +} + +fn duration_to_unix(duration: Duration) -> u64 { + duration.as_millis() as u64 +} + +impl<'a> Claims<'a> { + pub fn new(id: Uuid) -> Self { + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap(); + Self { + issuer: Cow::Borrowed("bankserver"), + subject: id, + issued_at: duration_to_unix(now), + expires: duration_to_unix(now + Duration::from_secs(60 * 60 * 3)), + } + } + pub fn encode( + &self, + key: &jsonwebtoken::EncodingKey, + ) -> Result { + jsonwebtoken::encode(&Header::new(Algorithm::HS512), self, key) + } + + pub fn decode( + token: &str, + key: &jsonwebtoken::DecodingKey, + ) -> Result { + Ok(jsonwebtoken::decode(token, key, &Validation::new(Algorithm::HS512))?.claims) + } +} + +fn invalid_username_or_password() -> ApiError<'static> { + ApiError::new( + StatusCode::FORBIDDEN, + "invalid_username_or_password", + "Invalid username or password", + ) +} + +#[derive(Debug, Clone)] +pub struct Auth { + user: Uuid, +} + +impl Auth { + pub const fn user_id(&self) -> Uuid { + self.user + } +} + +enum AuthError { + MissingHeader, + InvalidHeader, +} + +impl From for InnerError { + fn from(value: AuthError) -> Self { + match value { + AuthError::MissingHeader => InnerError::Plain(ApiError::const_new( + StatusCode::UNAUTHORIZED, + "auth.missing_header", + "Missing Authorization header", + )), + AuthError::InvalidHeader => InnerError::Plain(ApiError::const_new( + StatusCode::UNAUTHORIZED, + "auth.jwt.invalid", + "Invalid JWT", + )), + } + } +} + +fn auth_header(parts: &Parts) -> Result<&str, AuthError> { + let header = parts + .headers + .get("Authorization") + .ok_or(AuthError::MissingHeader)?; + let header = header.to_str().map_err(|_| AuthError::InvalidHeader)?; + let token = header + .strip_prefix("Bearer ") + .ok_or(AuthError::InvalidHeader)?; + Ok(token) +} + +impl FromRequestParts> for Auth { + type Rejection = Error; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let token = auth_header(parts)?; + let claims = Claims::decode(token, &state.decoding_key).map_err(|err| { + let _ = 0; + match err.kind() { + jsonwebtoken::errors::ErrorKind::ExpiredSignature => { + ApiError::const_new(StatusCode::UNAUTHORIZED, "auth.jwt.expired", "Expired JWT") + } + _ => { + ApiError::const_new(StatusCode::UNAUTHORIZED, "auth.jwt.invalid", "Invalid JWT") + } + } + })?; + Ok(Self { + user: claims.subject, + }) + } +} diff --git a/src/api/docs.rs b/src/api/docs.rs new file mode 100644 index 0000000..e043fbe --- /dev/null +++ b/src/api/docs.rs @@ -0,0 +1,46 @@ +use std::sync::Arc; + +use axum::{ + Router, + http::{HeaderValue, StatusCode}, + response::{Html, IntoResponse, Response}, + routing::get, +}; + +use super::AppState; + +static OPENAPI_JSON: &'static str = include_str!("../../openapi.json"); + +static SCALAR_DOCS: &'static str = include_str!("./docs/scalar.html"); +static SWAGGER_DOCS: &'static str = include_str!("./docs/swagger.html"); +static RAPIDOC_DOCS: &'static str = include_str!("./docs/rapidoc.html"); + +pub(super) fn router() -> Router> { + Router::new() + .route("/openapi.json", get(openapi_json)) + .route("/scalar", get(scalar_html)) + .route("/swagger", get(swagger_html)) + .route("/rapidoc", get(rapidoc_html)) +} + +fn with_content_type(body: &'static str, content_type: &'static str) -> Response { + let mut response = (StatusCode::OK, body).into_response(); + response + .headers_mut() + .insert("Content-Type", HeaderValue::from_static(content_type)); + response +} + +async fn openapi_json() -> Response { + with_content_type(OPENAPI_JSON, "application/json") +} + +async fn scalar_html() -> Html<&'static str> { + Html(SCALAR_DOCS) +} +async fn swagger_html() -> Html<&'static str> { + Html(SWAGGER_DOCS) +} +async fn rapidoc_html() -> Html<&'static str> { + Html(RAPIDOC_DOCS) +} diff --git a/src/api/docs/rapidoc.html b/src/api/docs/rapidoc.html new file mode 100644 index 0000000..6eda584 --- /dev/null +++ b/src/api/docs/rapidoc.html @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/api/docs/scalar.html b/src/api/docs/scalar.html new file mode 100644 index 0000000..b095b41 --- /dev/null +++ b/src/api/docs/scalar.html @@ -0,0 +1,16 @@ + + + + + Scalar API Reference + + + + + + + + + + + \ No newline at end of file diff --git a/src/api/docs/swagger.html b/src/api/docs/swagger.html new file mode 100644 index 0000000..2817736 --- /dev/null +++ b/src/api/docs/swagger.html @@ -0,0 +1,32 @@ + + + + + + + + SwaggerUI + + + + +
+ + + + + + \ No newline at end of file diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..c763b74 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,301 @@ +use std::{borrow::Cow, sync::Arc}; + +use axum::{ + Router, + extract::{FromRequest, Request}, + http::StatusCode, + response::IntoResponse, +}; + +use jsonwebtoken::{DecodingKey, EncodingKey}; +use serde::{Serialize, de::DeserializeOwned}; +use tracing_error::SpanTrace; + +pub use axum::extract::State as EState; + +mod account; +mod auth; +mod docs; +mod transactions; +mod user; + +#[derive(Debug, Clone, Copy, Default)] +pub struct Json(T); + +macro_rules! rejection_error { + ($id:expr, $rejection:expr) => {{ + let rejection = $rejection; + $crate::api::ApiError { + status: rejection.status(), + id: Cow::Borrowed($id), + message: Cow::Owned(rejection.body_text()), + } + }}; +} + +impl FromRequest for Json { + type Rejection = ApiError<'static>; + + async fn from_request(req: Request, state: &S) -> Result { + match axum::Json::::from_request(req, state).await { + Ok(value) => Ok(Self(value.0)), + Err(error) => Err(rejection_error!("malformed_body", error)), + } + } +} + +impl IntoResponse for Json { + #[inline] + fn into_response(self) -> axum::response::Response { + axum::Json(self.0).into_response() + } +} + +pub struct Error { + trace: Option, + inner: InnerError, +} + +#[derive(Debug)] +pub enum InnerError { + Pool(deadpool_postgres::PoolError), + Postgres(tokio_postgres::Error), + PHCParse(password_auth::ParseError), + Plain(ApiError<'static>), +} + +#[derive(Debug, Clone, Serialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct ApiError<'a> { + #[serde(skip)] + pub status: StatusCode, + pub id: Cow<'a, str>, + pub message: Cow<'a, str>, +} + +impl<'a> ApiError<'a> { + pub fn const_new(status: StatusCode, id: &'static str, message: &'static str) -> Self { + Self { + status, + id: Cow::Borrowed(id), + message: Cow::Borrowed(message), + } + } + pub fn new( + status: StatusCode, + id: impl Into>, + message: impl Into>, + ) -> Self { + Self { + status, + id: id.into(), + message: message.into(), + } + } + pub fn into_static(self) -> ApiError<'static> { + ApiError { + status: self.status, + id: Cow::Owned(self.id.into_owned()), + message: Cow::Owned(self.message.into_owned()), + } + } +} + +impl<'a> IntoResponse for ApiError<'a> { + fn into_response(self) -> axum::response::Response { + (self.status, axum::Json(self)).into_response() + } +} + +impl Error { + pub fn new(inner: InnerError) -> Self { + let trace = if cfg!(debug_assertions) || inner.internal() { + Some(SpanTrace::capture()) + } else { + None + }; + if cfg!(debug_assertions) { + tracing::error!(error = ?inner, "An error occurred"); + if let Some(trace) = &trace { + tracing::error!("Trace:\n{}", trace); + } + } + Self { trace, inner } + } +} + +impl> From for Error { + fn from(value: T) -> Self { + Error::new(value.into()) + } +} + +impl From for InnerError { + fn from(value: deadpool_postgres::PoolError) -> Self { + Self::Pool(value) + } +} +impl From for InnerError { + fn from(value: tokio_postgres::Error) -> Self { + Self::Postgres(value) + } +} + +impl From for InnerError { + fn from(value: password_auth::ParseError) -> Self { + Self::PHCParse(value) + } +} + +impl From> for InnerError { + fn from(value: ApiError<'static>) -> Self { + InnerError::Plain(value) + } +} + +impl InnerError { + pub const fn internal(&self) -> bool { + match self { + InnerError::Pool(_) => true, + InnerError::Postgres(_) => true, + InnerError::PHCParse(_) => true, + InnerError::Plain(err) => matches!(err.status, StatusCode::INTERNAL_SERVER_ERROR), + } + } +} + +impl IntoResponse for InnerError { + fn into_response(self) -> axum::response::Response { + match self { + InnerError::Pool(_) | InnerError::Postgres(_) | InnerError::PHCParse(_) => { + INTERNAL_SERVER_ERROR.clone().into_response() + } + InnerError::Plain(api_error) => api_error.into_response(), + } + } +} + +static INTERNAL_SERVER_ERROR: ApiError<'static> = ApiError { + status: StatusCode::INTERNAL_SERVER_ERROR, + id: Cow::Borrowed("internal_server_error"), + message: Cow::Borrowed("Internal Server Error"), +}; + +impl IntoResponse for Error { + fn into_response(self) -> axum::response::Response { + self.inner.into_response() + } +} + +#[derive(Clone)] +pub struct AppState { + pub pool: deadpool_postgres::Pool, + pub encoding_key: EncodingKey, + pub decoding_key: DecodingKey, +} + +impl AppState { + pub async fn conn(&self) -> Result { + self.pool.get().await + } +} + +pub type State = axum::extract::State>; + +pub fn router() -> Router> { + Router::new() + .merge(auth::router()) + .nest("/user", user::router()) + .nest("/docs", docs::router()) + .nest("/accounts", account::router()) + .nest("/transactions", transactions::router()) +} + +make_schemas!((ApiError); (), [crate::model::schemas, auth::schemas, user::schemas, transactions::schemas, account::schemas]); + +macro_rules! make_schemas { + (($($request_bodies:ty),*); ($($response_bodies:ty),*)$(, [$($deps:expr),*])? ) => { + #[cfg(feature = "schemas")] + #[allow(unused)] + pub const fn schemas() -> $crate::api::Schemas { + fn request_bodies(generator: &mut schemars::SchemaGenerator, schemas: &mut std::collections::HashMap, schemars::Schema>) { + $( + generator.subschema_for::<$request_bodies>(); + // schemas.insert(<$request_bodies as schemars::JsonSchema>::schema_name(), generator.root_schema_for::<$request_bodies>()); + )* + } + fn response_bodies(generator: &mut schemars::SchemaGenerator, schemas: &mut std::collections::HashMap, schemars::Schema>) { + $( + generator.subschema_for::<$response_bodies>(); + // schemas.insert(<$response_bodies as schemars::JsonSchema>::schema_name(), generator.root_schema_for::<$response_bodies>()); + )* + } + static DEPS: &'static [$crate::api::Schemas] = &[$($($deps()),*)?]; + $crate::api::Schemas { + request_bodies, + response_bodies, + contains: DEPS + } + } + #[cfg(not(feature = "schemas"))] + pub const fn schemas() -> $crate::api::Schemas { + $crate::api::Schemas + } + }; + ([$($deps:expr),*]) => { + #[cfg(feature = "schemas")] + pub const fn schemas() -> $crate::api::Schemas { + fn ident(_: &mut schemars::SchemaGenerator, _: &mut std::collections::HashMap, schemars::Schema>) {} + static DEPS: &'static [$crate::api::Schemas] = &[$($deps()),*]; + $crate::api::Schemas { + request_bodies: ident, + response_bodies: ident, + contains: DEPS + } + } + #[cfg(not(feature = "schemas"))] + pub const fn schemas() -> $crate::api::Schemas { + $crate::api::Schemas + } + }; +} + +pub(crate) use make_schemas; + +#[cfg(not(feature = "schemas"))] +pub struct Schemas; +#[cfg(feature = "schemas")] +pub struct Schemas { + pub request_bodies: fn( + &mut schemars::SchemaGenerator, + &mut std::collections::HashMap, schemars::Schema>, + ), + pub response_bodies: fn( + &mut schemars::SchemaGenerator, + &mut std::collections::HashMap, schemars::Schema>, + ), + pub contains: &'static [Self], +} + +#[cfg(feature = "schemas")] +impl Schemas { + pub fn generate( + &self, + requests: &mut schemars::SchemaGenerator, + responses: &mut schemars::SchemaGenerator, + request_schemas: &mut std::collections::HashMap< + std::borrow::Cow<'static, str>, + schemars::Schema, + >, + response_schemas: &mut std::collections::HashMap< + std::borrow::Cow<'static, str>, + schemars::Schema, + >, + ) { + for schemas in self.contains { + schemas.generate(requests, responses, request_schemas, response_schemas); + } + (self.request_bodies)(requests, request_schemas); + (self.response_bodies)(requests, response_schemas); + } +} diff --git a/src/api/transactions.rs b/src/api/transactions.rs new file mode 100644 index 0000000..05602d0 --- /dev/null +++ b/src/api/transactions.rs @@ -0,0 +1,57 @@ +use std::sync::Arc; + +use axum::{ + Router, + routing::{get, post}, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::model::{Transaction, UserInfo}; + +use super::{AppState, EState, Json, State, auth::Auth, make_schemas}; + +pub(super) fn router() -> Router> { + Router::new().route("/", get(transaction_history).post(make_payment)) +} + +make_schemas!((Direction, MakePayment); (TransactionHistory)); + +#[derive(Serialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct TransactionHistory { + transactions: Vec, +} + +#[derive(Deserialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +#[serde(rename_all = "lowercase")] +pub enum Direction { + Received, + Sent, +} + +#[derive(Deserialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +#[serde(untagged)] +pub enum NameOrUuid { + Id(Uuid), + Name(String), +} + +#[derive(Deserialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct MakePayment { + pub from: NameOrUuid, + pub to: NameOrUuid, + /// amount in cents + #[garde(min = 1)] + pub amount: u64, +} + +pub async fn transaction_history(EState(state): State, auth: Auth) { + // Transaction::get_all_for_user(client, id) +} +pub async fn make_payment(EState(state): State, auth: Auth, Json(body): Json) { + // Transaction::get_all_for_user(client, id) +} diff --git a/src/api/user.rs b/src/api/user.rs new file mode 100644 index 0000000..a78bc57 --- /dev/null +++ b/src/api/user.rs @@ -0,0 +1,59 @@ +use std::sync::Arc; + +use axum::{Router, extract::Path, routing::get}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::model::{UserAccountInfo, UserInfo}; + +use super::{AppState, EState, State, auth::Auth, make_schemas}; + +pub(super) fn router() -> Router> { + Router::new() + .route("/{target}", get(user_info)) + .route("/@me/balance", get(user_balance)) + .route("/", get(list_users)) +} + +#[derive(Deserialize)] +#[serde(untagged)] +pub enum UserTarget { + #[serde(rename = "@me")] + Me, + Id(Uuid), +} + +impl UserTarget { + pub fn user_id(&self, auth: &Auth) -> Uuid { + match self { + UserTarget::Me => auth.user_id(), + UserTarget::Id(uuid) => *uuid, + } + } +} + +make_schemas!((); (ListUsers, UserAccounts, UserBalance)); + +#[derive(Serialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct ListUsers { + users: Vec, +} + +#[derive(Serialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct UserAccounts { + pub accounts: Vec, +} + +#[derive(Serialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct UserBalance { + pub balance: u64, +} + +pub async fn user_info(EState(state): State, auth: Auth, Path(target): Path) { + let user = target.user_id(&auth); +} +pub async fn user_balance(EState(state): State, auth: Auth) {} +pub async fn list_users(EState(state): State, _: Auth) {} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..8fc7e90 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,36 @@ +use std::net::{IpAddr, Ipv6Addr, SocketAddr}; + +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct Config { + #[serde(default)] + pub database: deadpool_postgres::Config, + #[serde(default = "default_host")] + pub host: IpAddr, + #[serde(default = "default_port")] + pub port: u16, + pub jwt_key: String, +} + +fn default_host() -> IpAddr { + IpAddr::V6(Ipv6Addr::LOCALHOST) +} + +fn default_port() -> u16 { + 3845 +} + +impl Config { + pub fn load() -> Self { + let config = config::Config::builder() + .add_source(config::Environment::default().separator("__")) + .build() + .unwrap(); + config.try_deserialize().unwrap() + } + + pub fn socket_addr(&self) -> SocketAddr { + SocketAddr::new(self.host, self.port) + } +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..9d21a35 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,36 @@ +use tokio_postgres::NoTls; + +use crate::config::Config; + +mod migrations { + use dbmigrator::embed_migrations; + embed_migrations!("migrations"); + pub(super) async fn migrate(conn: &mut dyn dbmigrator::AsyncClient) { + let mut migrator = migrator( + dbmigrator::Config { + auto_initialize: true, + ..Default::default() + }, + dbmigrator::simple_compare, + ); + migrator.read_changelog(conn).await.unwrap(); + migrator.make_plan().unwrap(); + migrator.check_updated_log().unwrap(); + for plan in migrator.plans() { + migrator.apply_plan(conn, plan).await.unwrap(); + } + } +} + +pub async fn setup_db(config: &Config) -> deadpool_postgres::Pool { + let pool = config + .database + .create_pool(Some(deadpool::Runtime::Tokio1), NoTls) + .unwrap(); + + let mut conn = pool.get().await.unwrap(); + + migrations::migrate(&mut **conn).await; + + pool +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..7425428 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,106 @@ +pub mod api; +mod config; +mod db; +pub mod model; +pub use config::Config; +pub use db::setup_db; + +#[cfg(test)] +mod tests { + + #[cfg(feature = "schemas")] + #[test] + fn generate_schemas() { + use schemars::{ + SchemaGenerator, + generate::{Contract, SchemaSettings}, + transform::{Transform, transform_subschemas}, + }; + use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, + }; + let directory = PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/schemas")); + std::fs::create_dir_all(&directory).unwrap(); + let mut settings = SchemaSettings::draft2020_12(); + settings.definitions_path = "".to_owned(); + + let mut request_generator = SchemaGenerator::new(settings.clone()); + settings.contract = Contract::Serialize; + let mut response_generator = SchemaGenerator::new(settings); + + let mut request_schemas = HashMap::new(); + let mut response_schemas = HashMap::new(); + + struct RefTransform; + + impl schemars::transform::Transform for RefTransform { + fn transform(&mut self, schema: &mut schemars::Schema) { + if schema + .get("$ref") + .map(|value| value.as_str().map(|v| v.starts_with("#/"))) + .flatten() + .unwrap_or(false) + { + if let Some(value) = schema.remove("$ref") { + let mut value = value + .as_str() + .unwrap() + .strip_prefix("#/") + .unwrap() + .to_owned(); + value.push_str(".json"); + schema.insert("$ref".into(), value.into()); + } + } + transform_subschemas(self, schema); + } + } + + crate::api::schemas().generate( + &mut request_generator, + &mut response_generator, + &mut request_schemas, + &mut response_schemas, + ); + let mut request_defs = request_generator.take_definitions(); + let mut response_defs = response_generator.take_definitions(); + + let request_keys: HashSet = request_defs.keys().cloned().collect(); + let response_keys: HashSet = response_defs.keys().cloned().collect(); + + let mut schemas = HashMap::new(); + + for key in request_keys.union(&response_keys) { + let mut path = directory.join(key); + path.set_extension("json"); + let schema = match (request_defs.remove(key), response_defs.remove(key)) { + (None, Some(schema)) | (Some(schema), None) => schema, + (Some(request_schema), Some(response_schema)) => { + if request_schema != response_schema { + panic!("Diverging schema for {key}"); + } + request_schema + } + _ => continue, + }; + let mut schema: schemars::Schema = schema.try_into().unwrap(); + RefTransform.transform(&mut schema); + schemas.insert( + key, + serde_json::json!({"$ref": format!("schemas/{key}.json")}), + ); + std::fs::write( + path, + serde_json::to_string_pretty(schema.as_value()).unwrap(), + ) + .unwrap(); + } + std::fs::write( + directory.join("schemas.json"), + serde_json::to_string_pretty(&serde_json::json!({"components": {"schemas": schemas}})) + .unwrap(), + ) + .unwrap(); + } +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..3bd4e25 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,49 @@ -fn main() { - println!("Hello, world!"); +use std::sync::Arc; + +use axum::Router; +use bankserver::{Config, setup_db}; +use jsonwebtoken::{DecodingKey, EncodingKey}; +use tokio::net::TcpListener; +use tracing::level_filters::LevelFilter; +use tracing_error::ErrorLayer; +use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt}; + +#[tokio::main] +async fn main() { + let registry = Registry::default() + .with( + EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(), + ) + .with(ErrorLayer::default()) + .with(tracing_subscriber::fmt::layer()); + tracing::subscriber::set_global_default(registry).unwrap(); + // tracing_subscriber::fmt() + // .with_env_filter( + // EnvFilter::builder() + // .with_default_directive(LevelFilter::INFO.into()) + // .from_env_lossy(), + // ) + // .with(ErrorLayer::default()) + // .init(); + let config = Config::load(); + let pool = setup_db(&config).await; + + let encoding_key = EncodingKey::from_secret(config.jwt_key.as_bytes()); + let decoding_key = DecodingKey::from_secret(config.jwt_key.as_bytes()); + + let router = Router::new() + .nest("/api", bankserver::api::router()) + .with_state(Arc::new(bankserver::api::AppState { + pool, + encoding_key, + decoding_key, + })); + + let listener = TcpListener::bind(config.socket_addr()).await.unwrap(); + + axum::serve(listener, router.into_make_service()) + .await + .unwrap(); } diff --git a/src/model/account.rs b/src/model/account.rs new file mode 100644 index 0000000..88e7ea5 --- /dev/null +++ b/src/model/account.rs @@ -0,0 +1,111 @@ +use deadpool_postgres::GenericClient; +use serde::Serialize; +use tokio_postgres::Row; +use tracing::instrument; +use uuid::{NoContext, Timestamp, Uuid}; + +#[derive(Serialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct Account { + pub id: Uuid, + pub user: Uuid, + pub name: String, + pub balance: u64, +} + +#[derive(Serialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct AccountInfo { + pub id: Uuid, + pub user: Uuid, + pub name: String, +} +#[derive(Serialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct UserAccountInfo { + pub id: Uuid, + pub name: String, + pub balance: u64, +} + +impl From for AccountInfo { + fn from(value: Row) -> Self { + Self { + id: value.get("id"), + user: value.get("user"), + name: value.get("name"), + } + } +} + +impl From for UserAccountInfo { + fn from(value: Row) -> Self { + Self { + id: value.get("id"), + name: value.get("name"), + balance: value.get::<_, i64>("balance") as u64, + } + } +} + +impl Account { + #[instrument(skip(client))] + pub async fn create( + client: &mut impl GenericClient, + id: Option, + user: Uuid, + name: &str, + ) -> Result { + let stmt = client + .prepare_cached("insert into accounts(id, \"user\", name) values ($1, $2, $3)") + .await?; + let id = id.unwrap_or_else(|| Uuid::new_v7(Timestamp::now(NoContext))); + client.execute(&stmt, &[&id, &user, &name]).await?; + Ok(id) + } + + #[instrument(skip(client))] + pub async fn get_password_by_username( + client: &impl GenericClient, + name: &str, + ) -> Result, tokio_postgres::Error> { + let stmt = client + .prepare_cached("select password from users where name = $1") + .await?; + let res = client.query_opt(&stmt, &[&name]).await?; + Ok(res.map(|res| res.get(0))) + } + + #[instrument(skip(client))] + pub async fn list_all( + client: &impl GenericClient, + ) -> Result, tokio_postgres::Error> { + let stmt = client + .prepare_cached("select id,\"user\",name from accounts") + .await?; + let users = client + .query(&stmt, &[]) + .await? + .into_iter() + .map(AccountInfo::from) + .collect(); + Ok(users) + } + + #[instrument(skip(client))] + pub async fn list_for_user( + client: &impl GenericClient, + user: Uuid, + ) -> Result, tokio_postgres::Error> { + let stmt = client + .prepare_cached("select id,name,balance from accounts where \"user\" = $1") + .await?; + let users = client + .query(&stmt, &[&user]) + .await? + .into_iter() + .map(UserAccountInfo::from) + .collect(); + Ok(users) + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs new file mode 100644 index 0000000..7fcea5e --- /dev/null +++ b/src/model/mod.rs @@ -0,0 +1,11 @@ +mod account; +mod transaction; +mod user; + +pub use account::{Account, AccountInfo, UserAccountInfo}; +pub use transaction::Transaction; +pub use user::{User, UserInfo}; + +use crate::api::make_schemas; + +make_schemas!((); (UserInfo, Account, AccountInfo, UserAccountInfo, Transaction)); diff --git a/src/model/transaction.rs b/src/model/transaction.rs new file mode 100644 index 0000000..ba424d1 --- /dev/null +++ b/src/model/transaction.rs @@ -0,0 +1,121 @@ +use chrono::{DateTime, Utc}; +use deadpool_postgres::GenericClient; +use serde::Serialize; +use tokio_postgres::Row; +use uuid::Uuid; + +#[derive(Serialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct Transaction { + pub from: Uuid, + pub to: Uuid, + pub amount: u64, + pub timestamp: DateTime, + pub message: Option, +} + +impl From for Transaction { + fn from(value: Row) -> Self { + Self { + from: value.get("from"), + to: value.get("to"), + amount: value.get::<_, i64>("amount") as u64, + timestamp: value.get("timestamp"), + message: value.get("message"), + } + } +} + +impl Transaction { + pub async fn create( + client: &mut impl GenericClient, + from: Uuid, + to: Uuid, + amount: u64, + message: Option, + ) -> Result { + let stmt = client + .prepare_cached(r#"insert into transactions("from", "to", amount, message) values ($1, $2, $3, $4) returning timestamp"#) + .await?; + let row = client + .query_one(&stmt, &[&from, &to, &(amount as i64), &message]) + .await?; + let timestamp = row.get(0); + Ok(Self { + from, + to, + amount, + timestamp, + message, + }) + } + + pub async fn get_to_account( + client: &mut impl GenericClient, + id: Uuid, + ) -> Result, tokio_postgres::Error> { + let stmt = client + .prepare_cached("select * from transactions where \"to\" = $1") + .await?; + let transactions = client + .query(&stmt, &[&id]) + .await? + .into_iter() + .map(Transaction::from) + .collect(); + Ok(transactions) + } + + pub async fn get_from_account( + client: &mut impl GenericClient, + id: Uuid, + ) -> Result, tokio_postgres::Error> { + let stmt = client + .prepare_cached("select * from transactions where \"from\" = $1") + .await?; + let transactions = client + .query(&stmt, &[&id]) + .await? + .into_iter() + .map(Transaction::from) + .collect(); + Ok(transactions) + } + + pub async fn get_all_for_account( + client: &mut impl GenericClient, + id: Uuid, + ) -> Result, tokio_postgres::Error> { + let stmt = client + .prepare_cached("select * from transactions where \"from\" = $1 or \"to\" = $1") + .await?; + let transactions = client + .query(&stmt, &[&id]) + .await? + .into_iter() + .map(Transaction::from) + .collect(); + Ok(transactions) + } + + pub async fn get_all_to_user( + _client: &mut impl GenericClient, + _id: Uuid, + ) -> Result, tokio_postgres::Error> { + todo!() + } + + pub async fn get_all_from_user( + _client: &mut impl GenericClient, + _id: Uuid, + ) -> Result, tokio_postgres::Error> { + todo!() + } + + pub async fn get_all_for_user( + _client: &mut impl GenericClient, + _id: Uuid, + ) -> Result, tokio_postgres::Error> { + todo!() + } +} diff --git a/src/model/user.rs b/src/model/user.rs new file mode 100644 index 0000000..8bef270 --- /dev/null +++ b/src/model/user.rs @@ -0,0 +1,96 @@ +use axum::http::StatusCode; +use deadpool_postgres::GenericClient; +use serde::Serialize; +use tokio_postgres::{Row, error::SqlState}; +use tracing::instrument; +use uuid::{NoContext, Timestamp, Uuid}; + +use crate::api::{ApiError, Error}; + +use super::Account; + +pub struct User { + pub id: Uuid, + pub name: String, + pub password: String, +} + +#[derive(Serialize)] +#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))] +pub struct UserInfo { + pub id: Uuid, + pub name: String, +} + +impl From for UserInfo { + fn from(value: Row) -> Self { + Self { + id: value.get("id"), + name: value.get("name"), + } + } +} + +fn conflict_error() -> ApiError<'static> { + ApiError::new(StatusCode::CONFLICT, "conflict", "Conflict") +} + +fn unique_violation(error: tokio_postgres::Error, api_error: fn() -> ApiError<'static>) -> Error { + let Some(code) = error.code() else { + return error.into(); + }; + if *code == SqlState::UNIQUE_VIOLATION { + return api_error().into(); + } + return error.into(); +} + +impl User { + #[instrument(skip(client, password))] + pub async fn create( + client: &mut impl GenericClient, + name: &str, + password: &str, + ) -> Result { + let stmt = client + .prepare_cached("insert into users(id, name, password) values ($1, $2, $3)") + .await?; + let mut tx = client.transaction().await?; + let id = Uuid::new_v7(Timestamp::now(NoContext)); + tx.execute(&stmt, &[&id, &name, &password]) + .await + .map_err(|err| unique_violation(err, conflict_error))?; + Account::create(&mut tx, Some(id), id, name) + .await + .map_err(|err| unique_violation(err, conflict_error))?; + tx.commit().await?; + Ok(id) + } + + #[instrument(skip(client))] + pub async fn get_password_and_info_by_username( + client: &impl GenericClient, + name: &str, + ) -> Result, tokio_postgres::Error> { + let stmt = client + .prepare_cached("select id,name,password from users where name = $1") + .await?; + let res = client.query_opt(&stmt, &[&name]).await?; + Ok(res.map(|res| { + let password = res.get("password"); + (password, res.into()) + })) + } + + #[instrument(skip(client))] + pub async fn list(client: &impl GenericClient) -> Result, tokio_postgres::Error> { + let stmt = client.prepare_cached("select id,name from users").await?; + let users = client + .query(&stmt, &[]) + .await? + .into_iter() + .map(UserInfo::from) + .collect(); + Ok(users) + } +}