more work and ci

This commit is contained in:
DSeeLP 2025-02-28 20:48:44 +01:00
parent 0af10df617
commit f9d9639244
29 changed files with 2372 additions and 29 deletions

View File

@ -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 <nixpkgs> { 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

3
.gitignore vendored
View File

@ -1 +1,4 @@
/target
/result-*
/result
/schemas

1
.helix/ignore Normal file
View File

@ -0,0 +1 @@
/schemas

502
Cargo.lock generated
View File

@ -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",

View File

@ -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"] }

23
flake.lock generated
View File

@ -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"
}
}
},

View File

@ -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
];
};
});
};

12
justfile Normal file
View File

@ -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

View File

@ -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;

302
openapi-def.yaml Normal file
View File

@ -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

1
openapi.json Normal file
View File

@ -0,0 +1 @@

View File

@ -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;
}

26
src/api/account.rs Normal file
View File

@ -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<Arc<AppState>> {
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<AccountInfo>,
}
pub async fn account_info(EState(state): State, _: Auth, Path(id): Path<Uuid>) {}
pub async fn list_accounts(EState(state): State, _: Auth) {}

216
src/api/auth.rs Normal file
View File

@ -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<Arc<AppState>> {
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<Credentials>,
) -> Result<(StatusCode, Json<RegisterSuccess>), 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<Credentials>,
) -> Result<Json<LoginSuccess>, 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<String, jsonwebtoken::errors::Error> {
jsonwebtoken::encode(&Header::new(Algorithm::HS512), self, key)
}
pub fn decode(
token: &str,
key: &jsonwebtoken::DecodingKey,
) -> Result<Self, jsonwebtoken::errors::Error> {
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<AuthError> 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<Arc<AppState>> for Auth {
type Rejection = Error;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
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,
})
}
}

46
src/api/docs.rs Normal file
View File

@ -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<Arc<AppState>> {
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)
}

13
src/api/docs/rapidoc.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html> <!-- Important: must specify -->
<html>
<head>
<meta charset="utf-8"> <!-- Important: rapi-doc uses utf8 characters -->
<script type="module" src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"></script>
</head>
<body>
<rapi-doc spec-url="openapi.json"> </rapi-doc>
</body>
</html>

16
src/api/docs/scalar.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html>
<head>
<title>Scalar API Reference</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<!-- Need a Custom Header? Check out this example https://codepen.io/scalarorg/pen/VwOXqam -->
<script id="api-reference" data-url="openapi.json"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>

32
src/api/docs/swagger.html Normal file
View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="SwaggerUI" />
<title>SwaggerUI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js" crossorigin></script>
<script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-standalone-preset.js" crossorigin></script>
<script>
url = new URL("openapi.json", window.location.href).href;
window.onload = () => {
window.ui = SwaggerUIBundle({
url: url,
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
layout: "StandaloneLayout",
});
};
</script>
</body>
</html>

301
src/api/mod.rs Normal file
View File

@ -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>(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<T: DeserializeOwned, S: Send + Sync> FromRequest<S> for Json<T> {
type Rejection = ApiError<'static>;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
match axum::Json::<T>::from_request(req, state).await {
Ok(value) => Ok(Self(value.0)),
Err(error) => Err(rejection_error!("malformed_body", error)),
}
}
}
impl<T: Serialize> IntoResponse for Json<T> {
#[inline]
fn into_response(self) -> axum::response::Response {
axum::Json(self.0).into_response()
}
}
pub struct Error {
trace: Option<SpanTrace>,
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<Cow<'a, str>>,
message: impl Into<Cow<'a, str>>,
) -> 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<T: Into<InnerError>> From<T> for Error {
fn from(value: T) -> Self {
Error::new(value.into())
}
}
impl From<deadpool_postgres::PoolError> for InnerError {
fn from(value: deadpool_postgres::PoolError) -> Self {
Self::Pool(value)
}
}
impl From<tokio_postgres::Error> for InnerError {
fn from(value: tokio_postgres::Error) -> Self {
Self::Postgres(value)
}
}
impl From<password_auth::ParseError> for InnerError {
fn from(value: password_auth::ParseError) -> Self {
Self::PHCParse(value)
}
}
impl From<ApiError<'static>> 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<deadpool_postgres::Object, deadpool_postgres::PoolError> {
self.pool.get().await
}
}
pub type State = axum::extract::State<Arc<AppState>>;
pub fn router() -> Router<Arc<AppState>> {
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<std::borrow::Cow<'static, str>, 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<std::borrow::Cow<'static, str>, 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<std::borrow::Cow<'static, str>, 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<std::borrow::Cow<'static, str>, schemars::Schema>,
),
pub response_bodies: fn(
&mut schemars::SchemaGenerator,
&mut std::collections::HashMap<std::borrow::Cow<'static, str>, 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);
}
}

57
src/api/transactions.rs Normal file
View File

@ -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<Arc<AppState>> {
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<Transaction>,
}
#[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<MakePayment>) {
// Transaction::get_all_for_user(client, id)
}

59
src/api/user.rs Normal file
View File

@ -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<Arc<AppState>> {
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<UserInfo>,
}
#[derive(Serialize)]
#[cfg_attr(feature = "schemas", derive(schemars::JsonSchema))]
pub struct UserAccounts {
pub accounts: Vec<UserAccountInfo>,
}
#[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<UserTarget>) {
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) {}

36
src/config.rs Normal file
View File

@ -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)
}
}

36
src/db.rs Normal file
View File

@ -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
}

106
src/lib.rs Normal file
View File

@ -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<String> = request_defs.keys().cloned().collect();
let response_keys: HashSet<String> = 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();
}
}

View File

@ -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();
}

111
src/model/account.rs Normal file
View File

@ -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<Row> for AccountInfo {
fn from(value: Row) -> Self {
Self {
id: value.get("id"),
user: value.get("user"),
name: value.get("name"),
}
}
}
impl From<Row> 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<Uuid>,
user: Uuid,
name: &str,
) -> Result<Uuid, tokio_postgres::Error> {
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<Option<String>, 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<Vec<AccountInfo>, 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<Vec<UserAccountInfo>, 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)
}
}

11
src/model/mod.rs Normal file
View File

@ -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));

121
src/model/transaction.rs Normal file
View File

@ -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<Utc>,
pub message: Option<String>,
}
impl From<Row> 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<String>,
) -> Result<Transaction, tokio_postgres::Error> {
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<Vec<Transaction>, 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<Vec<Transaction>, 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<Vec<Transaction>, 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<Vec<Transaction>, tokio_postgres::Error> {
todo!()
}
pub async fn get_all_from_user(
_client: &mut impl GenericClient,
_id: Uuid,
) -> Result<Vec<Transaction>, tokio_postgres::Error> {
todo!()
}
pub async fn get_all_for_user(
_client: &mut impl GenericClient,
_id: Uuid,
) -> Result<Vec<Transaction>, tokio_postgres::Error> {
todo!()
}
}

96
src/model/user.rs Normal file
View File

@ -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<Row> 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<Uuid, Error> {
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<Option<(String, UserInfo)>, 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<Vec<UserInfo>, 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)
}
}