generated from phundrak/rust-poem-openapi-template
feat: OAuth implementation with Discord
This commit separates the core features of géjdr from the backend as these will also be used by the bot in the future. This commit also updates the dependencies of the project. It also removes the dependency lettre as well as the mailpit docker service for developers as it appears clearer this project won’t send emails anytime soon. The publication of a docker image is also postponed until later.
This commit is contained in:
parent
ae10711e41
commit
a2ea5a157d
@ -1,13 +1,12 @@
|
||||
;;; Directory Local Variables -*- no-byte-compile: t -*-
|
||||
;;; For more information see (info "(emacs) Directory Variables")
|
||||
|
||||
((sql-mode
|
||||
.
|
||||
((eval . (progn
|
||||
((rustic-mode . ((fill-column . 80)))
|
||||
(sql-mode . ((eval . (progn
|
||||
(setq-local lsp-sqls-connections
|
||||
`(((driver . "postgresql")
|
||||
(dataSourceName .
|
||||
,(format "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable"
|
||||
(dataSourceName \,
|
||||
(format "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable"
|
||||
(getenv "DB_HOST")
|
||||
(getenv "DB_PORT")
|
||||
(getenv "DB_USER")
|
||||
|
@ -45,8 +45,6 @@ jobs:
|
||||
run: nix develop --command -- just lint
|
||||
- name: Audit
|
||||
run: nix develop --command -- just audit
|
||||
- name: Minimum supported Rust version check
|
||||
run: nix develop --command -- just msrv
|
||||
- name: Tests
|
||||
run: nix develop --command -- just test
|
||||
- name: Coverage
|
||||
|
@ -9,6 +9,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'develop'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
@ -20,14 +21,26 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
registry: ${{ var.REGISTRY }}
|
||||
- uses: cachix/install-nix-action@v27
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
- name: Build Docker image
|
||||
run: nix develop --command -- just docker-build
|
||||
run: nix develop --command -- just backend build-docker
|
||||
- name: Load Docker image
|
||||
run: docker load < result
|
||||
- name: Docker Metadata action
|
||||
uses: docker/metadata-action@v5.5.1
|
||||
uses: docker/metadata-action@v5.6.1
|
||||
with:
|
||||
image: tal-backend:latest
|
||||
image: gejdr-backend:latest
|
||||
tags:
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha
|
||||
labels: |
|
||||
org.opencontainers.image.title=Backend GéJDR
|
||||
org.opencontainers.image.description=Backend for GéJDR
|
||||
org.opencontainers.image.vendor=Lucien Cartier-Tilet <lucien@phundrak.com>
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -3,3 +3,5 @@
|
||||
.env
|
||||
/result
|
||||
/coverage/
|
||||
/gejdr-backend/result
|
||||
/gejdr-bot/result
|
||||
|
64
.sqlx/query-24bbd63a324e36f4a0c559c44909621cc21b493b4c9ae4c14e30c99a6d8072bd.json
generated
Normal file
64
.sqlx/query-24bbd63a324e36f4a0c559c44909621cc21b493b4c9ae4c14e30c99a6d8072bd.json
generated
Normal file
@ -0,0 +1,64 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\nINSERT INTO users (id, username, email, avatar, name, created_at, last_updated)\nVALUES ($1, $2, $3, $4, $5, $6, $7)\nRETURNING *\n",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "avatar",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "last_updated",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "24bbd63a324e36f4a0c559c44909621cc21b493b4c9ae4c14e30c99a6d8072bd"
|
||||
}
|
14
.sqlx/query-50293c2e54af11d4c2a553e29b671cef087a159c6ee7182d8ca929ecb748f3b7.json
generated
Normal file
14
.sqlx/query-50293c2e54af11d4c2a553e29b671cef087a159c6ee7182d8ca929ecb748f3b7.json
generated
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM users WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "50293c2e54af11d4c2a553e29b671cef087a159c6ee7182d8ca929ecb748f3b7"
|
||||
}
|
58
.sqlx/query-843923b9a0257cf80f1dff554e7dc8fdfc05f489328e8376513124dfb42996e3.json
generated
Normal file
58
.sqlx/query-843923b9a0257cf80f1dff554e7dc8fdfc05f489328e8376513124dfb42996e3.json
generated
Normal file
@ -0,0 +1,58 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT * FROM users WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "avatar",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "last_updated",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "843923b9a0257cf80f1dff554e7dc8fdfc05f489328e8376513124dfb42996e3"
|
||||
}
|
63
.sqlx/query-c36467a81bad236a0c1a8d3fc1b5f8efda9b9cf9eaab140c10246218f0240600.json
generated
Normal file
63
.sqlx/query-c36467a81bad236a0c1a8d3fc1b5f8efda9b9cf9eaab140c10246218f0240600.json
generated
Normal file
@ -0,0 +1,63 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\nUPDATE users\nSET username = $1, email = $2, avatar = $3, name = $4, last_updated = $5\nWHERE id = $6\nRETURNING *\n",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "avatar",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "last_updated",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c36467a81bad236a0c1a8d3fc1b5f8efda9b9cf9eaab140c10246218f0240600"
|
||||
}
|
@ -3,3 +3,5 @@ out = ["Xml"]
|
||||
target-dir = "coverage"
|
||||
output-dir = "coverage"
|
||||
fail-under = 60
|
||||
exclude-files = ["target/*"]
|
||||
run-types = ["AllTargets"]
|
||||
|
@ -4,3 +4,5 @@ skip-clean = true
|
||||
target-dir = "coverage"
|
||||
output-dir = "coverage"
|
||||
fail-under = 60
|
||||
exclude-files = ["target/*"]
|
||||
run-types = ["AllTargets"]
|
1616
Cargo.lock
generated
1616
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
65
Cargo.toml
65
Cargo.toml
@ -1,61 +1,8 @@
|
||||
[package]
|
||||
name = "gege-jdr-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
authors = ["phundrak"]
|
||||
rust-version = "1.78"
|
||||
[workspace]
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
path = "src/main.rs"
|
||||
name = "gege-jdr-backend"
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4.38", features = ["serde"] }
|
||||
config = { version = "0.14.0", features = ["yaml"] }
|
||||
dotenvy = "0.15.7"
|
||||
serde = "1.0.204"
|
||||
serde_json = "1.0.120"
|
||||
thiserror = "1.0.63"
|
||||
tokio = { version = "1.39.2", features = ["macros", "rt-multi-thread"] }
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt", "std", "env-filter", "registry", "json", "tracing-log"] }
|
||||
uuid = { version = "1.10.0", features = ["v4", "serde"] }
|
||||
|
||||
[dependencies.lettre]
|
||||
version = "0.11.7"
|
||||
default-features = false
|
||||
features = [
|
||||
"builder",
|
||||
"hostname",
|
||||
"pool",
|
||||
"rustls-tls",
|
||||
"tokio1",
|
||||
"tokio1-rustls-tls",
|
||||
"smtp-transport"
|
||||
members = [
|
||||
"gejdr-core",
|
||||
"gejdr-bot",
|
||||
"gejdr-backend",
|
||||
]
|
||||
|
||||
|
||||
[dependencies.poem]
|
||||
version = "3.0.4"
|
||||
default-features = false
|
||||
features = [
|
||||
"csrf",
|
||||
"rustls",
|
||||
"cookie",
|
||||
"test"
|
||||
]
|
||||
|
||||
[dependencies.poem-openapi]
|
||||
version = "5.0.3"
|
||||
features = ["chrono", "swagger-ui", "uuid"]
|
||||
|
||||
[dependencies.sqlx]
|
||||
version = "0.8.0"
|
||||
default-features = false
|
||||
features = ["postgres", "uuid", "chrono", "migrate", "runtime-tokio", "macros"]
|
||||
resolver = "2"
|
@ -31,25 +31,6 @@ services:
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
# If you run GegeJdrBackend in production, DO NOT use mailpit.
|
||||
# This tool is for testing only. Instead, you should use a real SMTP
|
||||
# provider, such as Mailgun, Mailwhale, or Postal.
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
restart: unless-stopped
|
||||
container_name: gege-jdr-backend-mailpit
|
||||
ports:
|
||||
- 127.0.0.1:8025:8025 # WebUI
|
||||
- 127.0.0.1:1025:1025 # SMTP
|
||||
volumes:
|
||||
- gege_jdr_backend_mailpit:/data
|
||||
environment:
|
||||
MP_MAX_MESSAGES: 5000
|
||||
MP_DATABASE: /data/mailpit.db
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||
|
||||
volumes:
|
||||
gege_jdr_backend_db_data:
|
||||
gege_jdr_backend_pgadmin_data:
|
||||
gege_jdr_backend_mailpit:
|
||||
|
14
docker/mod.just
Normal file
14
docker/mod.just
Normal file
@ -0,0 +1,14 @@
|
||||
default: start
|
||||
|
||||
start:
|
||||
docker compose -f compose.dev.yml up -d
|
||||
|
||||
stop:
|
||||
docker compose -f compose.dev.yml down
|
||||
|
||||
logs:
|
||||
docker compose -f compose.dev.yml logs -f
|
||||
|
||||
## Local Variables:
|
||||
## mode: makefile
|
||||
## End:
|
12
flake.lock
generated
12
flake.lock
generated
@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1732014248,
|
||||
"narHash": "sha256-y/MEyuJ5oBWrWAic/14LaIr/u5E0wRVzyYsouYY3W6w=",
|
||||
"lastModified": 1736344531,
|
||||
"narHash": "sha256-8YVQ9ZbSfuUk2bUf2KRj60NRraLPKPS0Q4QFTbc+c2c=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "23e89b7da85c3640bbc2173fe04f4bd114342367",
|
||||
"rev": "bffc22eb12172e6db3c5dde9e3e5628f8e3e7912",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -62,11 +62,11 @@
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1732242723,
|
||||
"narHash": "sha256-NWI8csIK0ujFlFuEXKnoc+7hWoCiEtINK9r48LUUMeU=",
|
||||
"lastModified": 1736476219,
|
||||
"narHash": "sha256-+qyv3QqdZCdZ3cSO/cbpEY6tntyYjfe1bB12mdpNFaY=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "a229311fcb45b88a95fdfa5cecd8349c809a272a",
|
||||
"rev": "de30cc5963da22e9742bbbbb9a3344570ed237b9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
52
flake.nix
52
flake.nix
@ -18,30 +18,61 @@
|
||||
rustc = rustVersion;
|
||||
};
|
||||
|
||||
appName = "gege-jdr-backend";
|
||||
appNameBackend = "gejdr-backend";
|
||||
appNameBot = "gejdr-bot";
|
||||
|
||||
appRustBuild = rustPlatform.buildRustPackage {
|
||||
pname = appName;
|
||||
appRustBuildBackend = rustPlatform.buildRustPackage {
|
||||
pname = appNameBackend;
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
buildPhase = ''
|
||||
cd gejdr-backend
|
||||
SQLX_OFFLINE="1" cargo build --release --bin gejdr-backend
|
||||
'';
|
||||
installPhase = ''
|
||||
cargo install --path . --root "$out/bin/"
|
||||
'';
|
||||
};
|
||||
appRustBuildBot = rustPlatform.buildRustPackage {
|
||||
pname = appNameBot;
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
buildPhase = ''
|
||||
SQLX_OFFLINE="1" cargo build --release --bin gejdr-bot
|
||||
'';
|
||||
};
|
||||
|
||||
dockerImage = pkgs.dockerTools.buildLayeredImage {
|
||||
name = appName;
|
||||
dockerImageBackend = pkgs.dockerTools.buildLayeredImage {
|
||||
name = appNameBackend;
|
||||
tag = "latest";
|
||||
config = {
|
||||
Entrypoint = [ "${appRustBuild}/bin/${appName}" ];
|
||||
Entrypoint = [ "${appRustBuildBackend}/bin/${appNameBackend}" ];
|
||||
Env = [ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ];
|
||||
Tag = "latest";
|
||||
};
|
||||
contents = [ appRustBuild pkgs.cacert ];
|
||||
contents = [ appRustBuildBackend pkgs.cacert ];
|
||||
};
|
||||
dockerImageBot = pkgs.dockerTools.buildLayeredImage {
|
||||
name = appNameBot;
|
||||
tag = "latest";
|
||||
fromImageTag = "latest";
|
||||
config = {
|
||||
Entrypoint = [ "${appRustBuildBot}/bin/${appNameBot}" ];
|
||||
Env = [ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ];
|
||||
Tag = "latest";
|
||||
};
|
||||
contents = [ appRustBuildBot pkgs.cacert ];
|
||||
};
|
||||
in {
|
||||
packages = {
|
||||
rustPackage = appRustBuild;
|
||||
docker = dockerImage;
|
||||
backend = appRustBuildBot;
|
||||
bot = appRustBuildBot;
|
||||
dockerBackend = dockerImageBackend;
|
||||
dockerBot = dockerImageBot;
|
||||
};
|
||||
defaultPackage = dockerImage;
|
||||
defaultPackage = dockerImageBackend;
|
||||
devShell = with pkgs; mkShell {
|
||||
buildInputs = [
|
||||
bacon
|
||||
@ -49,7 +80,6 @@
|
||||
cargo-audit
|
||||
cargo-auditable
|
||||
cargo-tarpaulin
|
||||
cargo-msrv
|
||||
just
|
||||
rust-analyzer
|
||||
(rustVersion.override { extensions = [ "rust-src" ]; })
|
||||
|
6
gejdr-backend/.tarpaulin.ci.toml
Normal file
6
gejdr-backend/.tarpaulin.ci.toml
Normal file
@ -0,0 +1,6 @@
|
||||
[all]
|
||||
out = ["Xml"]
|
||||
target-dir = "coverage"
|
||||
output-dir = "coverage"
|
||||
fail-under = 60
|
||||
exclude-files = ["target/*"]
|
7
gejdr-backend/.tarpaulin.local.toml
Normal file
7
gejdr-backend/.tarpaulin.local.toml
Normal file
@ -0,0 +1,7 @@
|
||||
[all]
|
||||
out = ["Html", "Lcov"]
|
||||
skip-clean = true
|
||||
target-dir = "coverage"
|
||||
output-dir = "coverage"
|
||||
fail-under = 60
|
||||
exclude-files = ["target/*"]
|
43
gejdr-backend/Cargo.toml
Normal file
43
gejdr-backend/Cargo.toml
Normal file
@ -0,0 +1,43 @@
|
||||
[package]
|
||||
name = "gejdr-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
authors = ["Lucien Cartier-Tilet <lucien@phundrak.com>"]
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
path = "src/main.rs"
|
||||
name = "gejdr-backend"
|
||||
|
||||
[dependencies]
|
||||
gejdr-core = { path = "../gejdr-core" }
|
||||
chrono = { version = "0.4.38", features = ["serde"] }
|
||||
config = { version = "0.14.1", features = ["yaml"] }
|
||||
dotenvy = "0.15.7"
|
||||
oauth2 = "4.4.2"
|
||||
quote = "1.0.37"
|
||||
reqwest = { version = "0.12.9", default-features = false, features = ["charset", "h2", "http2", "rustls-tls", "json"] }
|
||||
serde = "1.0.215"
|
||||
serde_json = "1.0.133"
|
||||
thiserror = "1.0.69"
|
||||
tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread"] }
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt", "std", "env-filter", "registry", "json", "tracing-log"] }
|
||||
uuid = { version = "1.11.0", features = ["v4", "serde"] }
|
||||
|
||||
[dependencies.poem]
|
||||
version = "3.1.3"
|
||||
default-features = false
|
||||
features = ["csrf", "rustls", "cookie", "test", "session"]
|
||||
|
||||
[dependencies.poem-openapi]
|
||||
version = "5.1.2"
|
||||
features = ["chrono", "swagger-ui", "redoc", "rapidoc", "uuid"]
|
||||
|
||||
[lints.rust]
|
||||
unexpected_cfgs = { level = "allow", check-cfg = ['cfg(tarpaulin_include)'] }
|
18
gejdr-backend/backend.just
Normal file
18
gejdr-backend/backend.just
Normal file
@ -0,0 +1,18 @@
|
||||
default: run
|
||||
|
||||
build $SQLX_OFFLINE="1":
|
||||
pwd
|
||||
cargo auditable build --bin gejdr-backend
|
||||
|
||||
build-release $SQLX_OFFLINE="1":
|
||||
cargo auditable build --release --bin gejdr-backend
|
||||
|
||||
build-docker:
|
||||
nix build .#dockerBackend
|
||||
|
||||
run:
|
||||
cargo auditable run --bin gejdr-backend
|
||||
|
||||
## Local Variables:
|
||||
## mode: makefile
|
||||
## End:
|
@ -3,5 +3,5 @@ debug: true
|
||||
|
||||
application:
|
||||
protocol: http
|
||||
host: 127.0.0.1
|
||||
base_url: http://127.0.0.1:3000
|
||||
host: localhost
|
||||
base_url: http://localhost:3000
|
55
gejdr-backend/src/api_wrapper/discord.rs
Normal file
55
gejdr-backend/src/api_wrapper/discord.rs
Normal file
@ -0,0 +1,55 @@
|
||||
use super::{ApiError, DiscordErrorResponse};
|
||||
use gejdr_core::models::accounts::RemoteUser;
|
||||
|
||||
static DISCORD_URL: &str = "https://discord.com/api/v10/";
|
||||
|
||||
pub async fn get_user_profile(token: &str) -> Result<RemoteUser, ApiError> {
|
||||
let client = reqwest::Client::new();
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {token}").parse().unwrap(),
|
||||
);
|
||||
let response = client
|
||||
.get(format!("{DISCORD_URL}/users/@me"))
|
||||
.headers(headers)
|
||||
.send()
|
||||
.await;
|
||||
match response {
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
resp.json::<RemoteUser>()
|
||||
.await
|
||||
.map_err(std::convert::Into::into)
|
||||
} else {
|
||||
let error_response = resp.json::<DiscordErrorResponse>().await;
|
||||
match error_response {
|
||||
Ok(val) => Err(ApiError::Api(val)),
|
||||
Err(e) => Err(ApiError::Reqwest(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => Err(ApiError::Reqwest(e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_profile_invalid_token_results_in_401() {
|
||||
let res = get_user_profile("invalid").await;
|
||||
assert!(res.is_err());
|
||||
let err = res.err().unwrap();
|
||||
println!("Error: {err:?}");
|
||||
let expected = DiscordErrorResponse {
|
||||
code: 0,
|
||||
message: "401: Unauthorized".into(),
|
||||
};
|
||||
assert!(matches!(ApiError::Api(expected), _err));
|
||||
}
|
||||
|
||||
// TODO: Find a way to mock calls to discord.com API with a
|
||||
// successful reply
|
||||
}
|
41
gejdr-backend/src/api_wrapper/mod.rs
Normal file
41
gejdr-backend/src/api_wrapper/mod.rs
Normal file
@ -0,0 +1,41 @@
|
||||
use reqwest::Error as ReqwestError;
|
||||
use std::fmt::{self, Display};
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod discord;
|
||||
|
||||
#[derive(Debug, serde::Deserialize, PartialEq, Eq)]
|
||||
pub struct DiscordErrorResponse {
|
||||
pub message: String,
|
||||
pub code: u16,
|
||||
}
|
||||
|
||||
impl Display for DiscordErrorResponse {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "DiscordErrorResponse: {} ({})", self.message, self.code)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApiError {
|
||||
#[error("Reqwest error: {0}")]
|
||||
Reqwest(#[from] ReqwestError),
|
||||
#[error("API Error: {0}")]
|
||||
Api(DiscordErrorResponse),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn display_discord_error_response() {
|
||||
let error = DiscordErrorResponse {
|
||||
message: "Message".into(),
|
||||
code: 42,
|
||||
};
|
||||
let error_str = error.to_string();
|
||||
let expected = "DiscordErrorResponse: Message (42)".to_string();
|
||||
assert_eq!(expected, error_str);
|
||||
}
|
||||
}
|
14
gejdr-backend/src/errors.rs
Normal file
14
gejdr-backend/src/errors.rs
Normal file
@ -0,0 +1,14 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApiError {
|
||||
#[error("SQL error: {0}")]
|
||||
Sql(#[from] gejdr_core::sqlx::Error),
|
||||
#[error("OAuth token error: {0}")]
|
||||
TokenError(String),
|
||||
#[error("Unauthorized")]
|
||||
Unauthorized,
|
||||
#[error("Attempted to get a value, none found")]
|
||||
OptionError,
|
||||
}
|
@ -5,10 +5,14 @@
|
||||
#![allow(clippy::unused_async)]
|
||||
#![allow(clippy::useless_let_if_seq)] // Reason: prevents some OpenApi structs from compiling
|
||||
|
||||
pub mod route;
|
||||
pub mod settings;
|
||||
pub mod startup;
|
||||
pub mod telemetry;
|
||||
use gejdr_core::sqlx;
|
||||
|
||||
mod api_wrapper;
|
||||
mod errors;
|
||||
mod oauth;
|
||||
mod route;
|
||||
mod settings;
|
||||
mod startup;
|
||||
|
||||
type MaybeListener = Option<poem::listener::TcpListener<String>>;
|
||||
|
||||
@ -16,8 +20,8 @@ async fn prepare(listener: MaybeListener, test_db: Option<sqlx::PgPool>) -> star
|
||||
dotenvy::dotenv().ok();
|
||||
let settings = settings::Settings::new().expect("Failed to read settings");
|
||||
if !cfg!(test) {
|
||||
let subscriber = telemetry::get_subscriber(settings.clone().debug);
|
||||
telemetry::init_subscriber(subscriber);
|
||||
let subscriber = gejdr_core::telemetry::get_subscriber(settings.clone().debug);
|
||||
gejdr_core::telemetry::init_subscriber(subscriber);
|
||||
}
|
||||
tracing::event!(
|
||||
target: "gege-jdr-backend",
|
||||
@ -29,8 +33,8 @@ async fn prepare(listener: MaybeListener, test_db: Option<sqlx::PgPool>) -> star
|
||||
tracing::event!(
|
||||
target: "gege-jdr-backend",
|
||||
tracing::Level::INFO,
|
||||
"Listening on http://127.0.0.1:{}/",
|
||||
application.port()
|
||||
"Listening on {}",
|
||||
application.settings.web_address()
|
||||
);
|
||||
application
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), std::io::Error> {
|
||||
gege_jdr_backend::run(None).await
|
||||
gejdr_backend::run(None).await
|
||||
}
|
62
gejdr-backend/src/oauth/discord.rs
Normal file
62
gejdr-backend/src/oauth/discord.rs
Normal file
@ -0,0 +1,62 @@
|
||||
use oauth2::{
|
||||
basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken,
|
||||
PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RevocationUrl, Scope, TokenUrl,
|
||||
};
|
||||
use reqwest::Url;
|
||||
|
||||
use crate::{errors::ApiError, settings::Settings};
|
||||
|
||||
use super::OauthProvider;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscordOauthProvider {
|
||||
client: BasicClient,
|
||||
}
|
||||
|
||||
impl DiscordOauthProvider {
|
||||
pub fn new(settings: &Settings) -> Self {
|
||||
let redirect_url = format!("{}/v1/api/auth/callback/discord", settings.web_address());
|
||||
let auth_url = AuthUrl::new("https://discord.com/oauth2/authorize".to_string())
|
||||
.expect("Invalid authorization endpoint URL");
|
||||
let token_url = TokenUrl::new("https://discord.com/api/oauth2/token".to_string())
|
||||
.expect("Invalid token endpoint URL");
|
||||
let revocation_url =
|
||||
RevocationUrl::new("https://discord.com/api/oauth2/token/revoke".to_string())
|
||||
.expect("Invalid revocation URL");
|
||||
let client = BasicClient::new(
|
||||
ClientId::new(settings.discord.client_id.clone()),
|
||||
Some(ClientSecret::new(settings.discord.client_secret.clone())),
|
||||
auth_url,
|
||||
Some(token_url),
|
||||
)
|
||||
.set_redirect_uri(RedirectUrl::new(redirect_url).expect("Invalid redirect URL"))
|
||||
.set_revocation_uri(revocation_url);
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
impl OauthProvider for DiscordOauthProvider {
|
||||
fn auth_and_csrf(&self) -> (Url, CsrfToken, PkceCodeVerifier) {
|
||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
let (auth_url, csrf_token) = self
|
||||
.client
|
||||
.authorize_url(CsrfToken::new_random)
|
||||
.add_scopes(["identify", "openid", "email"].map(|v| Scope::new(v.to_string())))
|
||||
.set_pkce_challenge(pkce_challenge)
|
||||
.url();
|
||||
(auth_url, csrf_token, pkce_verifier)
|
||||
}
|
||||
|
||||
async fn token(
|
||||
&self,
|
||||
code: String,
|
||||
verifier: PkceCodeVerifier,
|
||||
) -> Result<super::Token, ApiError> {
|
||||
self.client
|
||||
.exchange_code(AuthorizationCode::new(code))
|
||||
.set_pkce_verifier(verifier)
|
||||
.request_async(oauth2::reqwest::async_http_client)
|
||||
.await
|
||||
.map_err(|e| ApiError::TokenError(format!("{e:?}")))
|
||||
}
|
||||
}
|
17
gejdr-backend/src/oauth/mod.rs
Normal file
17
gejdr-backend/src/oauth/mod.rs
Normal file
@ -0,0 +1,17 @@
|
||||
mod discord;
|
||||
pub use discord::DiscordOauthProvider;
|
||||
|
||||
use oauth2::{
|
||||
basic::BasicTokenType, CsrfToken, EmptyExtraTokenFields, PkceCodeVerifier,
|
||||
StandardTokenResponse,
|
||||
};
|
||||
use reqwest::Url;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
|
||||
pub type Token = StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>;
|
||||
|
||||
pub trait OauthProvider {
|
||||
fn auth_and_csrf(&self) -> (Url, CsrfToken, PkceCodeVerifier);
|
||||
async fn token(&self, code: String, verifier: PkceCodeVerifier) -> Result<Token, ApiError>;
|
||||
}
|
220
gejdr-backend/src/route/auth.rs
Normal file
220
gejdr-backend/src/route/auth.rs
Normal file
@ -0,0 +1,220 @@
|
||||
use gejdr_core::models::accounts::User;
|
||||
use gejdr_core::sqlx::PgPool;
|
||||
use oauth2::{CsrfToken, PkceCodeVerifier, TokenResponse};
|
||||
use poem::web::Data;
|
||||
use poem::{session::Session, web::Form};
|
||||
use poem_openapi::payload::{Json, PlainText};
|
||||
use poem_openapi::{ApiResponse, Object, OpenApi};
|
||||
|
||||
use crate::oauth::{DiscordOauthProvider, OauthProvider};
|
||||
|
||||
use super::errors::ErrorResponse;
|
||||
use super::ApiCategory;
|
||||
|
||||
type Token =
|
||||
oauth2::StandardTokenResponse<oauth2::EmptyExtraTokenFields, oauth2::basic::BasicTokenType>;
|
||||
|
||||
pub struct AuthApi;
|
||||
|
||||
#[derive(Debug, Object, Clone, Eq, PartialEq, serde::Deserialize)]
|
||||
struct DiscordCallbackRequest {
|
||||
code: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
impl DiscordCallbackRequest {
|
||||
pub fn check_token(&self, token: &CsrfToken) -> Result<(), LoginStatusResponse> {
|
||||
if *token.secret().to_string() == self.state {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(LoginStatusResponse::TokenError(Json(ErrorResponse {
|
||||
code: 500,
|
||||
message: "OAuth token error".into(),
|
||||
details: Some(
|
||||
"OAuth provider did not send a message that matches what was expected".into(),
|
||||
),
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(ApiResponse)]
|
||||
enum LoginStatusResponse {
|
||||
#[oai(status = 201)]
|
||||
LoggedIn(Json<UserInfo>),
|
||||
#[oai(status = 201)]
|
||||
LoggedOut(
|
||||
#[oai(header = "Location")] String,
|
||||
#[oai(header = "Cache-Control")] String,
|
||||
),
|
||||
#[oai(status = 301)]
|
||||
LoginRedirect(
|
||||
#[oai(header = "Location")] String,
|
||||
#[oai(header = "Cache-Control")] String,
|
||||
),
|
||||
#[oai(status = 500)]
|
||||
TokenError(Json<ErrorResponse>),
|
||||
#[oai(status = 500)]
|
||||
DatabaseError(Json<ErrorResponse>),
|
||||
#[oai(status = 503)]
|
||||
DiscordError(Json<ErrorResponse>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, serde::Serialize, Object)]
|
||||
struct UserInfo {
|
||||
id: String,
|
||||
username: String,
|
||||
display_name: Option<String>,
|
||||
avatar: Option<String>,
|
||||
}
|
||||
|
||||
impl From<User> for UserInfo {
|
||||
fn from(value: User) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
username: value.username,
|
||||
display_name: value.name,
|
||||
avatar: value.avatar,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(ApiResponse)]
|
||||
enum UserInfoResponse {
|
||||
#[oai(status = 201)]
|
||||
UserInfo(Json<UserInfo>),
|
||||
#[oai(status = 401)]
|
||||
Unauthorized,
|
||||
#[oai(status = 500)]
|
||||
DatabaseError(Json<ErrorResponse>),
|
||||
#[oai(status = 503)]
|
||||
DiscordError(Json<ErrorResponse>),
|
||||
}
|
||||
|
||||
impl From<UserInfoResponse> for LoginStatusResponse {
|
||||
fn from(value: UserInfoResponse) -> Self {
|
||||
match value {
|
||||
UserInfoResponse::UserInfo(json) => Self::LoggedIn(json),
|
||||
UserInfoResponse::Unauthorized => unimplemented!(),
|
||||
UserInfoResponse::DatabaseError(json) => Self::DatabaseError(json),
|
||||
UserInfoResponse::DiscordError(json) => Self::DiscordError(json),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(ApiResponse)]
|
||||
enum CsrfResponse {
|
||||
#[oai(status = 201)]
|
||||
Token(PlainText<String>),
|
||||
}
|
||||
|
||||
#[OpenApi(prefix_path = "/v1/api/auth", tag = "ApiCategory::Auth")]
|
||||
impl AuthApi {
|
||||
async fn fetch_remote_user(
|
||||
pool: Data<&PgPool>,
|
||||
token: Token,
|
||||
) -> Result<UserInfoResponse, UserInfoResponse> {
|
||||
crate::api_wrapper::discord::get_user_profile(token.access_token().secret())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::event!(
|
||||
target: "auth-discord",
|
||||
tracing::Level::ERROR,
|
||||
"Failed to communicate with Discord: {}",
|
||||
e
|
||||
);
|
||||
UserInfoResponse::DiscordError(Json(e.into()))
|
||||
})?
|
||||
.refresh_in_database(&pool)
|
||||
.await
|
||||
.map(|user| UserInfoResponse::UserInfo(Json(user.into())))
|
||||
.map_err(|e| {
|
||||
tracing::event!(
|
||||
target: "auth-discord",
|
||||
tracing::Level::ERROR,
|
||||
"Database error: {}",
|
||||
e
|
||||
);
|
||||
UserInfoResponse::DatabaseError(Json(e.into()))
|
||||
})
|
||||
}
|
||||
|
||||
#[oai(path = "/signin/discord", method = "get")]
|
||||
async fn signin_discord(
|
||||
&self,
|
||||
oauth: Data<&DiscordOauthProvider>,
|
||||
session: &Session,
|
||||
) -> LoginStatusResponse {
|
||||
let (auth_url, csrf_token, pkce_verifier) = oauth.0.auth_and_csrf();
|
||||
session.set("csrf", csrf_token);
|
||||
session.set("pkce", pkce_verifier);
|
||||
tracing::event!(
|
||||
target: "auth-discord",
|
||||
tracing::Level::INFO,
|
||||
"Signin through Discord",
|
||||
);
|
||||
LoginStatusResponse::LoginRedirect(auth_url.to_string(), "no-cache".to_string())
|
||||
}
|
||||
|
||||
#[oai(path = "/callback/discord", method = "get")]
|
||||
async fn callback_discord(
|
||||
&self,
|
||||
Form(auth_request): Form<DiscordCallbackRequest>,
|
||||
oauth: Data<&DiscordOauthProvider>,
|
||||
pool: Data<&PgPool>,
|
||||
session: &Session,
|
||||
) -> Result<LoginStatusResponse, LoginStatusResponse> {
|
||||
tracing::event!(
|
||||
target: "auth-discord",
|
||||
tracing::Level::INFO,
|
||||
"Discord callback",
|
||||
);
|
||||
let csrf_token = session.get::<CsrfToken>("csrf").ok_or_else(|| {
|
||||
LoginStatusResponse::TokenError(Json(ErrorResponse {
|
||||
code: 500,
|
||||
message: "Cannot fetch csrf token from session".to_string(),
|
||||
..Default::default()
|
||||
}))
|
||||
})?;
|
||||
auth_request.check_token(&csrf_token)?;
|
||||
let pkce_verifier = session.get::<PkceCodeVerifier>("pkce").ok_or_else(|| {
|
||||
LoginStatusResponse::TokenError(Json(ErrorResponse {
|
||||
code: 500,
|
||||
message: "Cannot fetch pkce verifier from session".to_string(),
|
||||
..Default::default()
|
||||
}))
|
||||
})?;
|
||||
let token = oauth
|
||||
.token(auth_request.code, pkce_verifier)
|
||||
.await
|
||||
.map_err(|e| LoginStatusResponse::TokenError(Json(e.into())))?;
|
||||
session.set("token", token.clone());
|
||||
Self::fetch_remote_user(pool, token)
|
||||
.await
|
||||
.map(std::convert::Into::into)
|
||||
.map_err(std::convert::Into::into)
|
||||
}
|
||||
|
||||
#[oai(path = "/csrf", method = "get")]
|
||||
async fn csrf(&self, token: &poem::web::CsrfToken) -> CsrfResponse {
|
||||
CsrfResponse::Token(PlainText(token.0.clone()))
|
||||
}
|
||||
|
||||
#[oai(path = "/signout", method = "post")]
|
||||
async fn signout(&self, session: &Session) -> LoginStatusResponse {
|
||||
session.purge();
|
||||
LoginStatusResponse::LoggedOut("/".to_string(), "no-cache".to_string())
|
||||
}
|
||||
|
||||
#[oai(path = "/me", method = "get")]
|
||||
async fn user_info(
|
||||
&self,
|
||||
session: &Session,
|
||||
pool: Data<&PgPool>,
|
||||
) -> Result<UserInfoResponse, UserInfoResponse> {
|
||||
let token = session
|
||||
.get::<Token>("token")
|
||||
.ok_or(UserInfoResponse::Unauthorized)?;
|
||||
Self::fetch_remote_user(pool, token).await
|
||||
}
|
||||
}
|
204
gejdr-backend/src/route/errors.rs
Normal file
204
gejdr-backend/src/route/errors.rs
Normal file
@ -0,0 +1,204 @@
|
||||
use poem_openapi::Object;
|
||||
use reqwest::Error as ReqwestError;
|
||||
|
||||
use crate::api_wrapper::ApiError as ApiWrapperError;
|
||||
use crate::errors::ApiError;
|
||||
|
||||
#[derive(Debug, serde::Serialize, Default, Object, PartialEq, Eq)]
|
||||
pub struct ErrorResponse {
|
||||
pub code: u16,
|
||||
pub message: String,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ApiError> for ErrorResponse {
|
||||
fn from(value: ApiError) -> Self {
|
||||
match value {
|
||||
ApiError::Sql(e) => Self {
|
||||
code: 500,
|
||||
message: "SQL error".into(),
|
||||
details: Some(e.to_string()),
|
||||
},
|
||||
ApiError::TokenError(e) => Self {
|
||||
code: 500,
|
||||
message: "OAuth token error".into(),
|
||||
details: Some(e),
|
||||
},
|
||||
ApiError::Unauthorized => Self {
|
||||
code: 401,
|
||||
message: "Unauthorized!".into(),
|
||||
..Default::default()
|
||||
},
|
||||
ApiError::OptionError => Self {
|
||||
code: 500,
|
||||
message: "Attempted to get a value, but none found".into(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ReqwestError> for ErrorResponse {
|
||||
fn from(value: ReqwestError) -> Self {
|
||||
Self {
|
||||
code: 503,
|
||||
message: "Failed to communicate with Discord".into(),
|
||||
details: Some(value.status().map_or_else(
|
||||
|| "Communication failed before we could hear back from Discord".into(),
|
||||
|status| format!("Discord sent back the error code {status}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiWrapperError> for ErrorResponse {
|
||||
fn from(source: ApiWrapperError) -> Self {
|
||||
match source {
|
||||
ApiWrapperError::Reqwest(e) => e.into(),
|
||||
ApiWrapperError::Api(e) => Self {
|
||||
code: if e.message.as_str().starts_with("401") {
|
||||
401
|
||||
} else {
|
||||
e.code
|
||||
},
|
||||
message: e.message,
|
||||
details: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<gejdr_core::sqlx::Error> for ErrorResponse {
|
||||
fn from(_value: gejdr_core::sqlx::Error) -> Self {
|
||||
Self {
|
||||
code: 500,
|
||||
message: "Internal database error".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api_wrapper::{ApiError as ApiWrapperError, DiscordErrorResponse};
|
||||
|
||||
#[test]
|
||||
fn conversion_from_sql_api_error_works() {
|
||||
let sql_error = ApiError::Sql(gejdr_core::sqlx::Error::ColumnNotFound(
|
||||
"COLUMN_NAME".to_string(),
|
||||
));
|
||||
let final_error = ErrorResponse::from(sql_error);
|
||||
let expected_error = ErrorResponse {
|
||||
code: 500,
|
||||
message: "SQL error".into(),
|
||||
details: Some("no column found for name: COLUMN_NAME".into()),
|
||||
};
|
||||
assert_eq!(expected_error, final_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_from_token_error_works() {
|
||||
let initial_error = ApiError::TokenError("TOKEN ERROR".into());
|
||||
let final_error: ErrorResponse = initial_error.into();
|
||||
let expected_error = ErrorResponse {
|
||||
code: 500,
|
||||
message: "OAuth token error".into(),
|
||||
details: Some("TOKEN ERROR".into()),
|
||||
};
|
||||
assert_eq!(expected_error, final_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_from_unauthorized_works() {
|
||||
let initial_error = ApiError::Unauthorized;
|
||||
let final_error: ErrorResponse = initial_error.into();
|
||||
let expected_error = ErrorResponse {
|
||||
code: 401,
|
||||
message: "Unauthorized!".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(expected_error, final_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_from_option_error_works() {
|
||||
let initial_error = ApiError::OptionError;
|
||||
let final_error: ErrorResponse = initial_error.into();
|
||||
let expected_error = ErrorResponse {
|
||||
code: 500,
|
||||
message: "Attempted to get a value, but none found".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(expected_error, final_error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn conversion_from_reqwest_error() {
|
||||
let err = reqwest::get("https://example.example/401").await;
|
||||
assert!(err.is_err());
|
||||
let expected = ErrorResponse {
|
||||
code: 503,
|
||||
message: "Failed to communicate with Discord".into(),
|
||||
details: Some("Communication failed before we could hear back from Discord".into()),
|
||||
};
|
||||
let actual: ErrorResponse = err.err().unwrap().into();
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn conversion_from_apiwrappererror_with_reqwest_error() {
|
||||
let err = reqwest::get("https://example.example/401").await;
|
||||
assert!(err.is_err());
|
||||
let err = ApiWrapperError::Reqwest(err.err().unwrap());
|
||||
let expected = ErrorResponse {
|
||||
code: 503,
|
||||
message: "Failed to communicate with Discord".into(),
|
||||
details: Some("Communication failed before we could hear back from Discord".into()),
|
||||
};
|
||||
let actual: ErrorResponse = err.into();
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_from_apiwrappererror_with_401_discord_error() {
|
||||
let err = ApiWrapperError::Api(DiscordErrorResponse {
|
||||
code: 0,
|
||||
message: "401: Unauthorized".into(),
|
||||
});
|
||||
let expected = ErrorResponse {
|
||||
code: 401,
|
||||
message: "401: Unauthorized".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let actual: ErrorResponse = err.into();
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_from_apiwrappererror_with_generic_discord_error() {
|
||||
let err = ApiWrapperError::Api(DiscordErrorResponse {
|
||||
code: 0,
|
||||
message: "Something else".into(),
|
||||
});
|
||||
let expected = ErrorResponse {
|
||||
code: 0,
|
||||
message: "Something else".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let actual: ErrorResponse = err.into();
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_from_database_error() {
|
||||
let err = gejdr_core::sqlx::Error::PoolClosed;
|
||||
let expected = ErrorResponse {
|
||||
code: 500,
|
||||
message: "Internal database error".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let actual: ErrorResponse = err.into();
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
}
|
@ -10,7 +10,7 @@ enum HealthResponse {
|
||||
|
||||
pub struct HealthApi;
|
||||
|
||||
#[OpenApi(prefix_path = "/v1/health-check", tag = "ApiCategory::Health")]
|
||||
#[OpenApi(prefix_path = "/v1/api/health-check", tag = "ApiCategory::Health")]
|
||||
impl HealthApi {
|
||||
#[oai(path = "/", method = "get")]
|
||||
async fn health_check(&self) -> HealthResponse {
|
||||
@ -23,7 +23,7 @@ impl HealthApi {
|
||||
async fn health_check_works() {
|
||||
let app = crate::get_test_app(None).await;
|
||||
let cli = poem::test::TestClient::new(app);
|
||||
let resp = cli.get("/v1/health-check").send().await;
|
||||
let resp = cli.get("/v1/api/health-check").send().await;
|
||||
resp.assert_status_is_ok();
|
||||
resp.assert_text("").await;
|
||||
}
|
@ -6,13 +6,19 @@ pub use health::HealthApi;
|
||||
mod version;
|
||||
pub use version::VersionApi;
|
||||
|
||||
mod errors;
|
||||
|
||||
mod auth;
|
||||
pub use auth::AuthApi;
|
||||
|
||||
#[derive(Tags)]
|
||||
enum ApiCategory {
|
||||
Auth,
|
||||
Health,
|
||||
Version,
|
||||
}
|
||||
|
||||
pub(crate) struct Api;
|
||||
pub struct Api;
|
||||
|
||||
#[OpenApi]
|
||||
impl Api {}
|
@ -25,7 +25,7 @@ enum VersionResponse {
|
||||
|
||||
pub struct VersionApi;
|
||||
|
||||
#[OpenApi(prefix_path = "/v1/version", tag = "ApiCategory::Version")]
|
||||
#[OpenApi(prefix_path = "/v1/api/version", tag = "ApiCategory::Version")]
|
||||
impl VersionApi {
|
||||
#[oai(path = "/", method = "get")]
|
||||
async fn version(&self, settings: poem::web::Data<&Settings>) -> Result<VersionResponse> {
|
||||
@ -38,7 +38,7 @@ impl VersionApi {
|
||||
async fn version_works() {
|
||||
let app = crate::get_test_app(None).await;
|
||||
let cli = poem::test::TestClient::new(app);
|
||||
let resp = cli.get("/v1/version").send().await;
|
||||
let resp = cli.get("/v1/api/version").send().await;
|
||||
resp.assert_status_is_ok();
|
||||
let json = resp.json().await;
|
||||
let json_value = json.value();
|
@ -1,4 +1,4 @@
|
||||
use sqlx::ConnectOptions;
|
||||
use gejdr_core::database::Database;
|
||||
|
||||
#[derive(Debug, serde::Deserialize, Clone, Default)]
|
||||
pub struct Settings {
|
||||
@ -13,16 +13,8 @@ pub struct Settings {
|
||||
impl Settings {
|
||||
#[must_use]
|
||||
pub fn web_address(&self) -> String {
|
||||
if self.debug {
|
||||
format!(
|
||||
"{}:{}",
|
||||
self.application.base_url.clone(),
|
||||
self.application.port
|
||||
)
|
||||
} else {
|
||||
self.application.base_url.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Multipurpose function that helps detect the current
|
||||
/// environment the application is running in using the
|
||||
@ -66,7 +58,7 @@ impl Settings {
|
||||
))
|
||||
.add_source(
|
||||
config::Environment::with_prefix("APP")
|
||||
.prefix_separator("_")
|
||||
.prefix_separator("__")
|
||||
.separator("__"),
|
||||
)
|
||||
.build()?;
|
||||
@ -84,35 +76,6 @@ pub struct ApplicationSettings {
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, Clone, Default)]
|
||||
pub struct Database {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub name: String,
|
||||
pub user: String,
|
||||
pub password: String,
|
||||
pub require_ssl: bool,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
#[must_use]
|
||||
pub fn get_connect_options(&self) -> sqlx::postgres::PgConnectOptions {
|
||||
let ssl_mode = if self.require_ssl {
|
||||
sqlx::postgres::PgSslMode::Require
|
||||
} else {
|
||||
sqlx::postgres::PgSslMode::Prefer
|
||||
};
|
||||
sqlx::postgres::PgConnectOptions::new()
|
||||
.host(&self.host)
|
||||
.username(&self.user)
|
||||
.password(&self.password)
|
||||
.port(self.port)
|
||||
.ssl_mode(ssl_mode)
|
||||
.database(&self.name)
|
||||
.log_statements(tracing::log::LevelFilter::Trace)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Environment {
|
||||
Development,
|
||||
@ -167,8 +130,8 @@ pub struct EmailSettings {
|
||||
|
||||
#[derive(Debug, serde::Deserialize, Clone, Default)]
|
||||
pub struct Discord {
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -235,7 +198,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn web_address_works() {
|
||||
let mut settings = Settings {
|
||||
let settings = Settings {
|
||||
debug: false,
|
||||
application: ApplicationSettings {
|
||||
base_url: "127.0.0.1".to_string(),
|
||||
@ -244,10 +207,7 @@ mod tests {
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let expected_no_debug = "127.0.0.1".to_string();
|
||||
let expected_debug = "127.0.0.1:3000".to_string();
|
||||
assert_eq!(expected_no_debug, settings.web_address());
|
||||
settings.debug = true;
|
||||
assert_eq!(expected_debug, settings.web_address());
|
||||
let expected = "127.0.0.1".to_string();
|
||||
assert_eq!(expected, settings.web_address());
|
||||
}
|
||||
}
|
@ -1,35 +1,36 @@
|
||||
use poem::middleware::Cors;
|
||||
use gejdr_core::sqlx;
|
||||
|
||||
use poem::middleware::{AddDataEndpoint, CorsEndpoint};
|
||||
use poem::middleware::{CookieJarManagerEndpoint, Cors};
|
||||
use poem::session::{CookieConfig, CookieSession, CookieSessionEndpoint};
|
||||
use poem::{EndpointExt, Route};
|
||||
use poem_openapi::OpenApiService;
|
||||
|
||||
use crate::oauth::DiscordOauthProvider;
|
||||
use crate::route::AuthApi;
|
||||
use crate::{
|
||||
route::{Api, HealthApi, VersionApi},
|
||||
settings::Settings,
|
||||
};
|
||||
|
||||
#[must_use]
|
||||
pub fn get_connection_pool(settings: &crate::settings::Database) -> sqlx::postgres::PgPool {
|
||||
tracing::event!(
|
||||
target: "startup",
|
||||
tracing::Level::INFO,
|
||||
"connecting to database with configuration {:?}",
|
||||
settings.clone()
|
||||
);
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.acquire_timeout(std::time::Duration::from_secs(2))
|
||||
.connect_lazy_with(settings.get_connect_options())
|
||||
}
|
||||
|
||||
type Server = poem::Server<poem::listener::TcpListener<String>, std::convert::Infallible>;
|
||||
pub type App = AddDataEndpoint<AddDataEndpoint<CorsEndpoint<Route>, sqlx::PgPool>, Settings>;
|
||||
pub type App = AddDataEndpoint<
|
||||
AddDataEndpoint<
|
||||
AddDataEndpoint<
|
||||
CookieJarManagerEndpoint<CookieSessionEndpoint<CorsEndpoint<Route>>>,
|
||||
DiscordOauthProvider,
|
||||
>,
|
||||
sqlx::Pool<sqlx::Postgres>,
|
||||
>,
|
||||
Settings,
|
||||
>;
|
||||
|
||||
pub struct Application {
|
||||
server: Server,
|
||||
app: poem::Route,
|
||||
port: u16,
|
||||
database: sqlx::postgres::PgPool,
|
||||
settings: Settings,
|
||||
pub settings: Settings,
|
||||
}
|
||||
|
||||
pub struct RunnableApplication {
|
||||
@ -61,6 +62,8 @@ impl From<Application> for RunnableApplication {
|
||||
let app = val
|
||||
.app
|
||||
.with(Cors::new())
|
||||
.with(CookieSession::new(CookieConfig::default().secure(true)))
|
||||
.data(crate::oauth::DiscordOauthProvider::new(&val.settings))
|
||||
.data(val.database)
|
||||
.data(val.settings);
|
||||
let server = val.server;
|
||||
@ -74,16 +77,16 @@ impl Application {
|
||||
test_pool: Option<sqlx::postgres::PgPool>,
|
||||
) -> sqlx::postgres::PgPool {
|
||||
let database_pool =
|
||||
test_pool.map_or_else(|| get_connection_pool(&settings.database), |pool| pool);
|
||||
test_pool.map_or_else(|| settings.database.get_connection_pool(), |pool| pool);
|
||||
if !cfg!(test) {
|
||||
migrate_database(&database_pool).await;
|
||||
gejdr_core::database::Database::migrate(&database_pool).await;
|
||||
}
|
||||
database_pool
|
||||
}
|
||||
|
||||
fn setup_app(settings: &Settings) -> poem::Route {
|
||||
let api_service = OpenApiService::new(
|
||||
(Api, HealthApi, VersionApi),
|
||||
(Api, AuthApi, HealthApi, VersionApi),
|
||||
settings.application.clone().name,
|
||||
settings.application.clone().version,
|
||||
);
|
||||
@ -104,6 +107,7 @@ impl Application {
|
||||
});
|
||||
poem::Server::new(tcp_listener)
|
||||
}
|
||||
|
||||
pub async fn build(
|
||||
settings: Settings,
|
||||
test_pool: Option<sqlx::postgres::PgPool>,
|
||||
@ -133,10 +137,3 @@ impl Application {
|
||||
self.port
|
||||
}
|
||||
}
|
||||
|
||||
async fn migrate_database(pool: &sqlx::postgres::PgPool) {
|
||||
sqlx::migrate!()
|
||||
.run(pool)
|
||||
.await
|
||||
.expect("Failed to migrate the database");
|
||||
}
|
1
gejdr-bot/.gitignore
vendored
Normal file
1
gejdr-bot/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
6
gejdr-bot/Cargo.toml
Normal file
6
gejdr-bot/Cargo.toml
Normal file
@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "gejdr-bot"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
3
gejdr-bot/src/main.rs
Normal file
3
gejdr-bot/src/main.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
}
|
1
gejdr-core/.gitignore
vendored
Normal file
1
gejdr-core/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
16
gejdr-core/Cargo.toml
Normal file
16
gejdr-core/Cargo.toml
Normal file
@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "gejdr-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4.38", features = ["serde"] }
|
||||
serde = "1.0.215"
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["fmt", "std", "env-filter", "registry", "json", "tracing-log"] }
|
||||
uuid = { version = "1.11.0", features = ["v4", "serde"] }
|
||||
|
||||
[dependencies.sqlx]
|
||||
version = "0.8.2"
|
||||
default-features = false
|
||||
features = ["postgres", "uuid", "chrono", "migrate", "runtime-tokio", "macros"]
|
3
gejdr-core/migrations/20240809173617_users.down.sql
Normal file
3
gejdr-core/migrations/20240809173617_users.down.sql
Normal file
@ -0,0 +1,3 @@
|
||||
-- Add down migration script here
|
||||
DROP TABLE IF EXISTS public.users;
|
||||
DROP EXTENSION IF EXISTS "uuid-ossp";
|
15
gejdr-core/migrations/20240809173617_users.up.sql
Normal file
15
gejdr-core/migrations/20240809173617_users.up.sql
Normal file
@ -0,0 +1,15 @@
|
||||
-- Add up migration script here
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.users
|
||||
(
|
||||
id character varying(255) NOT NULL,
|
||||
username character varying(255) NOT NULL,
|
||||
email character varying(255),
|
||||
avatar character varying(511),
|
||||
name character varying(255),
|
||||
created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_updated timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT users_email_unique UNIQUE (email)
|
||||
);
|
50
gejdr-core/src/database.rs
Normal file
50
gejdr-core/src/database.rs
Normal file
@ -0,0 +1,50 @@
|
||||
use sqlx::ConnectOptions;
|
||||
|
||||
#[derive(Debug, serde::Deserialize, Clone, Default)]
|
||||
pub struct Database {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub name: String,
|
||||
pub user: String,
|
||||
pub password: String,
|
||||
pub require_ssl: bool,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
#[must_use]
|
||||
pub fn get_connect_options(&self) -> sqlx::postgres::PgConnectOptions {
|
||||
let ssl_mode = if self.require_ssl {
|
||||
sqlx::postgres::PgSslMode::Require
|
||||
} else {
|
||||
sqlx::postgres::PgSslMode::Prefer
|
||||
};
|
||||
sqlx::postgres::PgConnectOptions::new()
|
||||
.host(&self.host)
|
||||
.username(&self.user)
|
||||
.password(&self.password)
|
||||
.port(self.port)
|
||||
.ssl_mode(ssl_mode)
|
||||
.database(&self.name)
|
||||
.log_statements(tracing::log::LevelFilter::Trace)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_connection_pool(&self) -> sqlx::postgres::PgPool {
|
||||
tracing::event!(
|
||||
target: "startup",
|
||||
tracing::Level::INFO,
|
||||
"connecting to database with configuration {:?}",
|
||||
self.clone()
|
||||
);
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.acquire_timeout(std::time::Duration::from_secs(2))
|
||||
.connect_lazy_with(self.get_connect_options())
|
||||
}
|
||||
|
||||
pub async fn migrate(pool: &sqlx::PgPool) {
|
||||
sqlx::migrate!()
|
||||
.run(pool)
|
||||
.await
|
||||
.expect("Failed to migrate the database");
|
||||
}
|
||||
}
|
4
gejdr-core/src/lib.rs
Normal file
4
gejdr-core/src/lib.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub mod database;
|
||||
pub mod models;
|
||||
pub mod telemetry;
|
||||
pub use sqlx;
|
396
gejdr-core/src/models/accounts.rs
Normal file
396
gejdr-core/src/models/accounts.rs
Normal file
@ -0,0 +1,396 @@
|
||||
use sqlx::PgPool;
|
||||
|
||||
type Timestampz = chrono::DateTime<chrono::Utc>;
|
||||
|
||||
#[derive(serde::Deserialize, PartialEq, Eq, Debug, Clone, Default)]
|
||||
pub struct RemoteUser {
|
||||
id: String,
|
||||
username: String,
|
||||
global_name: Option<String>,
|
||||
email: Option<String>,
|
||||
avatar: Option<String>,
|
||||
}
|
||||
|
||||
impl RemoteUser {
|
||||
/// Refresh in database the row related to the remote user. Maybe
|
||||
/// create a row for this user if needed.
|
||||
pub async fn refresh_in_database(self, pool: &PgPool) -> Result<User, sqlx::Error> {
|
||||
match User::find(pool, &self.id).await? {
|
||||
Some(local_user) => local_user.update_from_remote(self).update(pool).await,
|
||||
None => User::from(self).save(pool).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Debug, PartialEq, Eq, Default, Clone)]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub avatar: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub created_at: Timestampz,
|
||||
pub last_updated: Timestampz,
|
||||
}
|
||||
|
||||
impl From<RemoteUser> for User {
|
||||
fn from(value: RemoteUser) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
username: value.username,
|
||||
email: value.email,
|
||||
avatar: value.avatar,
|
||||
name: value.global_name,
|
||||
created_at: chrono::offset::Utc::now(),
|
||||
last_updated: chrono::offset::Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<RemoteUser> for User {
|
||||
#[allow(clippy::suspicious_operation_groupings)]
|
||||
fn eq(&self, other: &RemoteUser) -> bool {
|
||||
self.id == other.id
|
||||
&& self.username == other.username
|
||||
&& self.email == other.email
|
||||
&& self.avatar == other.avatar
|
||||
&& self.name == other.global_name
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<User> for RemoteUser {
|
||||
fn eq(&self, other: &User) -> bool {
|
||||
other == self
|
||||
}
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn update_from_remote(self, from: RemoteUser) -> Self {
|
||||
if self == from {
|
||||
self
|
||||
} else {
|
||||
Self {
|
||||
username: from.username,
|
||||
email: from.email,
|
||||
avatar: from.avatar,
|
||||
name: from.global_name,
|
||||
last_updated: chrono::offset::Utc::now(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find(pool: &PgPool, id: &String) -> Result<Option<Self>, sqlx::Error> {
|
||||
sqlx::query_as!(Self, r#"SELECT * FROM users WHERE id = $1"#, id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn save(&self, pool: &PgPool) -> Result<Self, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
r#"
|
||||
INSERT INTO users (id, username, email, avatar, name, created_at, last_updated)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *
|
||||
"#,
|
||||
self.id,
|
||||
self.username,
|
||||
self.email,
|
||||
self.avatar,
|
||||
self.name,
|
||||
self.created_at,
|
||||
self.last_updated
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update(&self, pool: &PgPool) -> Result<Self, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
Self,
|
||||
r#"
|
||||
UPDATE users
|
||||
SET username = $1, email = $2, avatar = $3, name = $4, last_updated = $5
|
||||
WHERE id = $6
|
||||
RETURNING *
|
||||
"#,
|
||||
self.username,
|
||||
self.email,
|
||||
self.avatar,
|
||||
self.name,
|
||||
self.last_updated,
|
||||
self.id
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn save_or_update(&self, pool: &PgPool) -> Result<Self, sqlx::Error> {
|
||||
if Self::find(pool, &self.id).await?.is_some() {
|
||||
self.update(pool).await
|
||||
} else {
|
||||
self.save(pool).await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &PgPool, id: &String) -> Result<u64, sqlx::Error> {
|
||||
let rows_affected = sqlx::query!("DELETE FROM users WHERE id = $1", id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(rows_affected)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn convert_remote_user_to_local_user() {
|
||||
let remote = RemoteUser {
|
||||
id: "user-id".into(),
|
||||
username: "username".into(),
|
||||
global_name: None,
|
||||
email: Some("user@example.com".into()),
|
||||
avatar: None,
|
||||
};
|
||||
let local: User = remote.into();
|
||||
let expected = User {
|
||||
id: "user-id".into(),
|
||||
username: "username".into(),
|
||||
email: Some("user@example.com".into()),
|
||||
avatar: None,
|
||||
name: None,
|
||||
created_at: local.created_at,
|
||||
last_updated: local.last_updated,
|
||||
};
|
||||
assert_eq!(expected, local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_compare_remote_and_local_user() {
|
||||
let remote_same = RemoteUser {
|
||||
id: "user-id".into(),
|
||||
username: "username".into(),
|
||||
global_name: None,
|
||||
email: Some("user@example.com".into()),
|
||||
avatar: None,
|
||||
};
|
||||
let remote_different = RemoteUser {
|
||||
id: "user-id".into(),
|
||||
username: "username".into(),
|
||||
global_name: None,
|
||||
email: Some("user@example.com".into()),
|
||||
avatar: Some("some-hash".into()),
|
||||
};
|
||||
let local = User {
|
||||
id: "user-id".into(),
|
||||
username: "username".into(),
|
||||
email: Some("user@example.com".into()),
|
||||
avatar: None,
|
||||
name: None,
|
||||
created_at: chrono::offset::Utc::now(),
|
||||
last_updated: chrono::offset::Utc::now(),
|
||||
};
|
||||
assert_eq!(remote_same, local);
|
||||
assert_ne!(remote_different, local);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn add_new_remote_users_in_database(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let remote1 = RemoteUser {
|
||||
id: "id1".into(),
|
||||
username: "user1".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let remote2 = RemoteUser {
|
||||
id: "id2".into(),
|
||||
username: "user2".into(),
|
||||
..Default::default()
|
||||
};
|
||||
remote1.refresh_in_database(&pool).await?;
|
||||
remote2.refresh_in_database(&pool).await?;
|
||||
let users = sqlx::query_as!(User, "SELECT * FROM users")
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
assert_eq!(2, users.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("accounts"))]
|
||||
async fn update_local_users_in_db_from_remote(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let users = sqlx::query_as!(User, "SELECT * FROM users")
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
assert_eq!(2, users.len());
|
||||
let remote1 = RemoteUser {
|
||||
id: "id1".into(),
|
||||
username: "user1-new".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let remote2 = RemoteUser {
|
||||
id: "id2".into(),
|
||||
username: "user2-new".into(),
|
||||
..Default::default()
|
||||
};
|
||||
remote1.refresh_in_database(&pool).await?;
|
||||
remote2.refresh_in_database(&pool).await?;
|
||||
let users = sqlx::query_as!(User, "SELECT * FROM users")
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
assert_eq!(2, users.len());
|
||||
users
|
||||
.iter()
|
||||
.for_each(|user| assert!(user.last_updated > user.created_at));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_local_user_from_identical_remote_shouldnt_change_local() {
|
||||
let remote = RemoteUser {
|
||||
id: "id1".into(),
|
||||
username: "user1".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let local = User {
|
||||
id: "id1".into(),
|
||||
username: "user1".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let new_local = local.clone().update_from_remote(remote);
|
||||
assert_eq!(local, new_local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_local_user_from_different_remote() {
|
||||
let remote = RemoteUser {
|
||||
id: "id1".into(),
|
||||
username: "user2".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let local = User {
|
||||
id: "id1".into(),
|
||||
username: "user1".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let new_local = local.clone().update_from_remote(remote.clone());
|
||||
assert_ne!(remote, local);
|
||||
assert_eq!(remote, new_local);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn save_user_in_database(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let user = User {
|
||||
id: "id1".into(),
|
||||
username: "user1".into(),
|
||||
..Default::default()
|
||||
};
|
||||
user.save(&pool).await?;
|
||||
let users = sqlx::query_as!(User, "SELECT * FROM users")
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
assert_eq!(1, users.len());
|
||||
assert_eq!(Some(user), users.first().cloned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("accounts"))]
|
||||
async fn update_user_in_database(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let db_user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = 'id1'")
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
assert!(db_user.name.is_none());
|
||||
let user = User {
|
||||
id: "id1".into(),
|
||||
username: "user1".into(),
|
||||
name: Some("Cool Name".into()),
|
||||
..Default::default()
|
||||
};
|
||||
user.update(&pool).await?;
|
||||
let db_user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = 'id1'")
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
assert!(db_user.name.is_some());
|
||||
assert_eq!(Some("Cool Name".to_string()), db_user.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn save_or_update_saves_if_no_exist(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let rows = sqlx::query_as!(User, "SELECT * FROM users")
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
assert_eq!(0, rows.len());
|
||||
let user = User {
|
||||
id: "id1".into(),
|
||||
username: "user1".into(),
|
||||
..Default::default()
|
||||
};
|
||||
user.save_or_update(&pool).await?;
|
||||
let rows = sqlx::query_as!(User, "SELECT * FROM users")
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
assert_eq!(1, rows.len());
|
||||
let db_user = rows.first();
|
||||
assert_eq!(Some(user), db_user.cloned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("accounts"))]
|
||||
async fn save_or_update_updates_if_exists(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let rows = sqlx::query_as!(User, "SELECT * FROM users")
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
assert_eq!(2, rows.len());
|
||||
let user = User {
|
||||
id: "id1".into(),
|
||||
username: "user1".into(),
|
||||
name: Some("Cool Nam".into()),
|
||||
..Default::default()
|
||||
};
|
||||
user.save_or_update(&pool).await?;
|
||||
let rows = sqlx::query_as!(User, "SELECT * FROM users")
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
assert_eq!(2, rows.len());
|
||||
let db_user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = 'id1'")
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
assert_eq!(user.name, db_user.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("accounts"))]
|
||||
async fn delete_removes_account_from_db(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let rows = sqlx::query_as!(User, "SELECT * FROM users")
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
assert_eq!(2, rows.len());
|
||||
let id = "id1".to_string();
|
||||
let deletions = User::delete(&pool, &id).await?;
|
||||
assert_eq!(1, deletions);
|
||||
let rows = sqlx::query_as!(User, "SELECT * FROM users")
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
assert_eq!(1, rows.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("accounts"))]
|
||||
async fn delete_with_wrong_id_shouldnt_delete_anything(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let rows = sqlx::query_as!(User, "SELECT * FROM users")
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
assert_eq!(2, rows.len());
|
||||
let id = "invalid".to_string();
|
||||
let deletions = User::delete(&pool, &id).await?;
|
||||
assert_eq!(0, deletions);
|
||||
let rows = sqlx::query_as!(User, "SELECT * FROM users")
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
assert_eq!(2, rows.len());
|
||||
Ok(())
|
||||
}
|
||||
}
|
2
gejdr-core/src/models/fixtures/accounts.sql
Normal file
2
gejdr-core/src/models/fixtures/accounts.sql
Normal file
@ -0,0 +1,2 @@
|
||||
INSERT INTO users (id, username) VALUES ('id1', 'user1');
|
||||
INSERT INTO users (id, username) VALUES ('id2', 'user2');
|
1
gejdr-core/src/models/mod.rs
Normal file
1
gejdr-core/src/models/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod accounts;
|
@ -5,7 +5,7 @@ pub fn get_subscriber(debug: bool) -> impl tracing::Subscriber + Send + Sync {
|
||||
let env_filter = if debug { "debug" } else { "info" }.to_string();
|
||||
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(env_filter));
|
||||
let stdout_log = tracing_subscriber::fmt::layer().pretty();
|
||||
let stdout_log = tracing_subscriber::fmt::layer().pretty().with_test_writer();
|
||||
let subscriber = tracing_subscriber::Registry::default()
|
||||
.with(env_filter)
|
||||
.with(stdout_log);
|
58
justfile
58
justfile
@ -1,10 +1,7 @@
|
||||
default: run
|
||||
mod backend 'gejdr-backend/backend.just'
|
||||
mod docker
|
||||
|
||||
prepare:
|
||||
cargo sqlx prepare
|
||||
|
||||
migrate:
|
||||
sqlx migrate run
|
||||
default: lint
|
||||
|
||||
format:
|
||||
cargo fmt --all
|
||||
@ -12,38 +9,30 @@ format:
|
||||
format-check:
|
||||
cargo fmt --check --all
|
||||
|
||||
build:
|
||||
cargo auditable build
|
||||
migrate:
|
||||
sqlx migrate run --source gejdr-core/migrations
|
||||
|
||||
build-release:
|
||||
cargo auditable build --release
|
||||
build $SQLX_OFFLINE="1":
|
||||
cargo auditable build --bin gejdr-backend
|
||||
cargo auditable build --bin gejdr-bot
|
||||
|
||||
run: docker-start
|
||||
cargo auditable run
|
||||
|
||||
run-no-docker:
|
||||
cargo auditable run
|
||||
build-release $SQLX_OFFLINE="1":
|
||||
cargo auditable build --release --bin gejdr-backend
|
||||
cargo auditable build --release --bin gejdr-bot
|
||||
|
||||
lint:
|
||||
cargo clippy --all-targets
|
||||
|
||||
msrv:
|
||||
cargo msrv verify
|
||||
|
||||
release-build:
|
||||
cargo auditable build --release
|
||||
|
||||
release-run:
|
||||
cargo auditable run --release
|
||||
|
||||
audit: build
|
||||
cargo audit bin target/debug/gege-jdr-backend
|
||||
cargo audit bin target/debug/gejdr-backend
|
||||
cargo audit bin target/debug/gejdr-bot
|
||||
|
||||
audit-release: build-release
|
||||
cargo audit bin target/release/gege-jdr-backend
|
||||
cargo audit bin target/release/gejdr-backend
|
||||
cargo audit bin target/release/gejdr-bot
|
||||
|
||||
test:
|
||||
cargo test
|
||||
cargo test --all-targets --all
|
||||
|
||||
coverage:
|
||||
mkdir -p coverage
|
||||
@ -53,19 +42,10 @@ coverage-ci:
|
||||
mkdir -p coverage
|
||||
cargo tarpaulin --config .tarpaulin.ci.toml
|
||||
|
||||
check-all: format-check lint msrv coverage audit
|
||||
check-all: format-check lint coverage audit
|
||||
|
||||
docker-build:
|
||||
nix build .#docker
|
||||
|
||||
docker-start:
|
||||
docker compose -f docker/compose.dev.yml up -d
|
||||
|
||||
docker-stop:
|
||||
docker compose -f docker/compose.dev.yml down
|
||||
|
||||
docker-logs:
|
||||
docker compose -f docker/compose.dev.yml logs -f
|
||||
docker-backend $SQLX_OFFLINE="1":
|
||||
nix build .#dockerBackend
|
||||
|
||||
## Local Variables:
|
||||
## mode: makefile
|
||||
|
@ -1,5 +0,0 @@
|
||||
-- Add down migration script here
|
||||
ALTER TABLE IF EXISTS public.sessions DROP CONSTRAINT IF EXISTS sessions_user_id_users_fk;
|
||||
DROP TABLE IF EXISTS public.sessions;
|
||||
DROP TABLE IF EXISTS public.users;
|
||||
DROP EXTENSION IF EXISTS "uuid-ossp";
|
@ -1,29 +0,0 @@
|
||||
-- Add up migration script here
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.users
|
||||
(
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
email character varying(255) NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_updated timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT users_email_unique UNIQUE (email)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.sessions
|
||||
(
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
user_id uuid NOT NULL,
|
||||
session_id character varying NOT NULL,
|
||||
expires_at timestamp with time zone NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT sessions_user_id_unique UNIQUE (user_id)
|
||||
);
|
||||
|
||||
ALTER TABLE IF EXISTS public.sessions
|
||||
ADD CONSTRAINT sessions_user_id_users_fk FOREIGN KEY (user_id)
|
||||
REFERENCES public.users (id) MATCH SIMPLE
|
||||
ON UPDATE CASCADE
|
||||
ON DELETE CASCADE
|
||||
NOT VALID;
|
Loading…
Reference in New Issue
Block a user