feat: rust project initialization
This commit is contained in:
24
.envrc
Normal file
24
.envrc
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
if ! has nix_direnv_version || ! nix_direnv_version 3.1.0; then
|
||||||
|
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.1.0/direnvrc" "sha256-yMJ2OVMzrFaDPn7q8nCBZFRYpL/f0RcHzhmw/i6btJM="
|
||||||
|
fi
|
||||||
|
|
||||||
|
export DEVENV_IN_DIRENV_SHELL=true
|
||||||
|
|
||||||
|
# Load .env file if present
|
||||||
|
dotenv_if_exists
|
||||||
|
|
||||||
|
watch_file flake.nix
|
||||||
|
watch_file flake.lock
|
||||||
|
watch_file .envrc.local
|
||||||
|
watch_file nix/shell.nix
|
||||||
|
|
||||||
|
# Check if .envrc.local exists and contains a shell preference
|
||||||
|
if [[ -f .envrc.local ]]; then
|
||||||
|
source .envrc.local
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! use flake . --no-pure-eval; then
|
||||||
|
echo "Devenv could not be built. The devenv environment was not loaded. Make the necessary changes to flake.nix and hit enter to try again." >&2
|
||||||
|
fi
|
||||||
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
.devenv
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
target/
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
## Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
## Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Nix
|
||||||
|
result
|
||||||
|
.data/
|
||||||
|
|
||||||
|
|
||||||
|
# Added by cargo
|
||||||
|
|
||||||
|
/target
|
||||||
6
.tarpaulin.ci.toml
Normal file
6
.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
.tarpaulin.local.toml
Normal file
7
.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/*", "private/*"]
|
||||||
2699
Cargo.lock
generated
Normal file
2699
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
Cargo.toml
Normal file
31
Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
[package]
|
||||||
|
name = "sta"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
publish = false
|
||||||
|
authors = ["Lucien Cartier-Tilet <lucien@phundrak.com>"]
|
||||||
|
license = "AGPL-3.0-only"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
path = "src/main.rs"
|
||||||
|
name = "sta"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chrono = { version = "0.4.42", features = ["serde"] }
|
||||||
|
config = { version = "0.15.19", features = ["yaml"] }
|
||||||
|
dotenvy = "0.15.7"
|
||||||
|
governor = "0.8.1"
|
||||||
|
poem = { version = "3.1.12", default-features = false, features = ["csrf", "rustls", "test"] }
|
||||||
|
poem-openapi = { version = "5.1.16", features = ["chrono", "swagger-ui"] }
|
||||||
|
serde = "1.0.228"
|
||||||
|
serde_json = "1.0.148"
|
||||||
|
thiserror = "2.0.17"
|
||||||
|
tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] }
|
||||||
|
tracing = "0.1.44"
|
||||||
|
tracing-subscriber = { version = "0.3.22", features = ["fmt", "std", "env-filter", "registry", "json", "tracing-log"] }
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] }
|
||||||
84
bacon.toml
Normal file
84
bacon.toml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# This is a configuration file for the bacon tool
|
||||||
|
#
|
||||||
|
# Bacon repository: https://github.com/Canop/bacon
|
||||||
|
# Complete help on configuration: https://dystroy.org/bacon/config/
|
||||||
|
# You can also check bacon's own bacon.toml file
|
||||||
|
# as an example: https://github.com/Canop/bacon/blob/main/bacon.toml
|
||||||
|
|
||||||
|
default_job = "clippy-all"
|
||||||
|
|
||||||
|
[jobs.check]
|
||||||
|
command = ["cargo", "check", "--color", "always"]
|
||||||
|
need_stdout = false
|
||||||
|
|
||||||
|
[jobs.check-all]
|
||||||
|
command = ["cargo", "check", "--all-targets", "--color", "always"]
|
||||||
|
need_stdout = false
|
||||||
|
|
||||||
|
# Run clippy on the default target
|
||||||
|
[jobs.clippy]
|
||||||
|
command = [
|
||||||
|
"cargo", "clippy",
|
||||||
|
"--color", "always",
|
||||||
|
]
|
||||||
|
need_stdout = false
|
||||||
|
|
||||||
|
[jobs.clippy-all]
|
||||||
|
command = [
|
||||||
|
"cargo", "clippy",
|
||||||
|
"--all-targets",
|
||||||
|
"--color", "always",
|
||||||
|
]
|
||||||
|
need_stdout = false
|
||||||
|
|
||||||
|
[jobs.test]
|
||||||
|
command = [
|
||||||
|
"cargo", "test", "--color", "always",
|
||||||
|
"--", "--color", "always", # see https://github.com/Canop/bacon/issues/124
|
||||||
|
]
|
||||||
|
need_stdout = true
|
||||||
|
|
||||||
|
[jobs.doc]
|
||||||
|
command = ["cargo", "doc", "--color", "always", "--no-deps"]
|
||||||
|
need_stdout = false
|
||||||
|
|
||||||
|
# If the doc compiles, then it opens in your browser and bacon switches
|
||||||
|
# to the previous job
|
||||||
|
[jobs.doc-open]
|
||||||
|
command = ["cargo", "doc", "--color", "always", "--no-deps", "--open"]
|
||||||
|
need_stdout = false
|
||||||
|
on_success = "back" # so that we don't open the browser at each change
|
||||||
|
|
||||||
|
# You can run your application and have the result displayed in bacon,
|
||||||
|
# *if* it makes sense for this crate.
|
||||||
|
# Don't forget the `--color always` part or the errors won't be
|
||||||
|
# properly parsed.
|
||||||
|
# If your program never stops (eg a server), you may set `background`
|
||||||
|
# to false to have the cargo run output immediately displayed instead
|
||||||
|
# of waiting for program's end.
|
||||||
|
[jobs.run]
|
||||||
|
command = [
|
||||||
|
"cargo", "run",
|
||||||
|
"--color", "always",
|
||||||
|
# put launch parameters for your program behind a `--` separator
|
||||||
|
]
|
||||||
|
need_stdout = true
|
||||||
|
allow_warnings = true
|
||||||
|
background = true
|
||||||
|
|
||||||
|
# This parameterized job runs the example of your choice, as soon
|
||||||
|
# as the code compiles.
|
||||||
|
# Call it as
|
||||||
|
# bacon ex -- my-example
|
||||||
|
[jobs.ex]
|
||||||
|
command = ["cargo", "run", "--color", "always", "--example"]
|
||||||
|
need_stdout = true
|
||||||
|
allow_warnings = true
|
||||||
|
|
||||||
|
# You may define here keybindings that would be specific to
|
||||||
|
# a project, for example a shortcut to launch a specific job.
|
||||||
|
# Shortcuts to internal functions (scrolling, toggling, etc.)
|
||||||
|
# should go in your personal global prefs.toml file instead.
|
||||||
|
[keybindings]
|
||||||
|
# alt-m = "job:my-job"
|
||||||
|
c = "job:clippy-all" # comment this to have 'c' run clippy on only the default target
|
||||||
51
deny.toml
Normal file
51
deny.toml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
[output]
|
||||||
|
feature-depth = 1
|
||||||
|
|
||||||
|
[advisories]
|
||||||
|
ignore = [
|
||||||
|
"RUSTSEC-2025-0134"
|
||||||
|
]
|
||||||
|
|
||||||
|
[licenses]
|
||||||
|
# List of explicitly allowed licenses
|
||||||
|
# See https://spdx.org/licenses/ for list of possible licenses
|
||||||
|
allow = [
|
||||||
|
"AGPL-3.0-only",
|
||||||
|
"Apache-2.0 WITH LLVM-exception",
|
||||||
|
"Apache-2.0",
|
||||||
|
"BSD-3-Clause",
|
||||||
|
"ISC",
|
||||||
|
"MIT",
|
||||||
|
"MPL-2.0",
|
||||||
|
"OpenSSL",
|
||||||
|
"Unicode-3.0",
|
||||||
|
"Zlib",
|
||||||
|
]
|
||||||
|
confidence-threshold = 0.8
|
||||||
|
exceptions = []
|
||||||
|
|
||||||
|
[licenses.private]
|
||||||
|
ignore = false
|
||||||
|
registries = []
|
||||||
|
|
||||||
|
[bans]
|
||||||
|
multiple-versions = "allow"
|
||||||
|
wildcards = "allow"
|
||||||
|
highlight = "all"
|
||||||
|
workspace-default-features = "allow"
|
||||||
|
external-default-features = "allow"
|
||||||
|
allow = []
|
||||||
|
deny = []
|
||||||
|
skip = []
|
||||||
|
skip-tree = []
|
||||||
|
|
||||||
|
[sources]
|
||||||
|
unknown-registry = "deny"
|
||||||
|
unknown-git = "deny"
|
||||||
|
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||||
|
allow-git = []
|
||||||
|
|
||||||
|
[sources.allow-org]
|
||||||
|
github = []
|
||||||
|
gitlab = []
|
||||||
|
bitbucket = []
|
||||||
358
flake.lock
generated
Normal file
358
flake.lock
generated
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"alejandra": {
|
||||||
|
"inputs": {
|
||||||
|
"fenix": "fenix",
|
||||||
|
"flakeCompat": "flakeCompat",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1744324181,
|
||||||
|
"narHash": "sha256-Oi1n2ncF4/AWeY6X55o2FddIRICokbciqFYK64XorYk=",
|
||||||
|
"owner": "kamadorueda",
|
||||||
|
"repo": "alejandra",
|
||||||
|
"rev": "3e2a85506627062313e131bf8a85315f3387c8e0",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "kamadorueda",
|
||||||
|
"ref": "4.0.0",
|
||||||
|
"repo": "alejandra",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cachix": {
|
||||||
|
"inputs": {
|
||||||
|
"devenv": [
|
||||||
|
"devenv"
|
||||||
|
],
|
||||||
|
"flake-compat": [
|
||||||
|
"devenv",
|
||||||
|
"flake-compat"
|
||||||
|
],
|
||||||
|
"git-hooks": [
|
||||||
|
"devenv",
|
||||||
|
"git-hooks"
|
||||||
|
],
|
||||||
|
"nixpkgs": [
|
||||||
|
"devenv",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1760971495,
|
||||||
|
"narHash": "sha256-IwnNtbNVrlZIHh7h4Wz6VP0Furxg9Hh0ycighvL5cZc=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "cachix",
|
||||||
|
"rev": "c5bfd933d1033672f51a863c47303fc0e093c2d2",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"ref": "latest",
|
||||||
|
"repo": "cachix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devenv": {
|
||||||
|
"inputs": {
|
||||||
|
"cachix": "cachix",
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"flake-parts": "flake-parts",
|
||||||
|
"git-hooks": "git-hooks",
|
||||||
|
"nix": "nix",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1766843567,
|
||||||
|
"narHash": "sha256-062oL6KZCH7ePf4BBG61OdFJUh5ovw6zTpd/lVwy/xk=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"rev": "d0f2c8545f09e5aba9d321079a284b550371879d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devenv-root": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"narHash": "sha256-d6xi4mKdjkX2JFicDIv5niSzpyI0m/Hnm8GGAIU04kY=",
|
||||||
|
"type": "file",
|
||||||
|
"url": "file:///dev/null"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"type": "file",
|
||||||
|
"url": "file:///dev/null"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fenix": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"alejandra",
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"rust-analyzer-src": "rust-analyzer-src"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1730615655,
|
||||||
|
"narHash": "sha256-2HBR3zLn57LXKNRtxBb+O+uDqHM4n0pz51rPayMl4cg=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "fenix",
|
||||||
|
"rev": "efeb50e2535b17ffd4a135e6e3e5fd60a525180c",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "fenix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1761588595,
|
||||||
|
"narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-parts": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs-lib": [
|
||||||
|
"devenv",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1760948891,
|
||||||
|
"narHash": "sha256-TmWcdiUUaWk8J4lpjzu4gCGxWY6/Ok7mOK4fIFfBuU4=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"rev": "864599284fc7c0ba6357ed89ed5e2cd5040f0c04",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flakeCompat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1696426674,
|
||||||
|
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"git-hooks": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": [
|
||||||
|
"devenv",
|
||||||
|
"flake-compat"
|
||||||
|
],
|
||||||
|
"gitignore": "gitignore",
|
||||||
|
"nixpkgs": [
|
||||||
|
"devenv",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1760663237,
|
||||||
|
"narHash": "sha256-BflA6U4AM1bzuRMR8QqzPXqh8sWVCNDzOdsxXEguJIc=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"rev": "ca5b894d3e3e151ffc1db040b6ce4dcc75d31c37",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitignore": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"devenv",
|
||||||
|
"git-hooks",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1709087332,
|
||||||
|
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nix": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": [
|
||||||
|
"devenv",
|
||||||
|
"flake-compat"
|
||||||
|
],
|
||||||
|
"flake-parts": [
|
||||||
|
"devenv",
|
||||||
|
"flake-parts"
|
||||||
|
],
|
||||||
|
"git-hooks-nix": [
|
||||||
|
"devenv",
|
||||||
|
"git-hooks"
|
||||||
|
],
|
||||||
|
"nixpkgs": [
|
||||||
|
"devenv",
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"nixpkgs-23-11": [
|
||||||
|
"devenv"
|
||||||
|
],
|
||||||
|
"nixpkgs-regression": [
|
||||||
|
"devenv"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1761648602,
|
||||||
|
"narHash": "sha256-H97KSB/luq/aGobKRuHahOvT1r7C03BgB6D5HBZsbN8=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "nix",
|
||||||
|
"rev": "3e5644da6830ef65f0a2f7ec22830c46285bfff6",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"ref": "devenv-2.30.6",
|
||||||
|
"repo": "nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1764580874,
|
||||||
|
"narHash": "sha256-GMlWyeVh6fVuPeJI+ZmbJVV8DDS5wfdfDY88FHt5g/8=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv-nixpkgs",
|
||||||
|
"rev": "dcf61356c3ab25f1362b4a4428a6d871e84f1d1d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"ref": "rolling",
|
||||||
|
"repo": "devenv-nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"alejandra": "alejandra",
|
||||||
|
"devenv": "devenv",
|
||||||
|
"devenv-root": "devenv-root",
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-analyzer-src": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1730555913,
|
||||||
|
"narHash": "sha256-KNHZUlqsEibg3YtfUyOFQSofP8hp1HKoY+laoesBxRM=",
|
||||||
|
"owner": "rust-lang",
|
||||||
|
"repo": "rust-analyzer",
|
||||||
|
"rev": "f17a5bbfd0969ba2e63a74505a80e55ecb174ed9",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "rust-lang",
|
||||||
|
"ref": "nightly",
|
||||||
|
"repo": "rust-analyzer",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1766803264,
|
||||||
|
"narHash": "sha256-eGK6He8BR6L7N73kyyjz/vGxZX1Usnr8Gwfs3D18KgE=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "6b5c52313aaf3f3e1a0a6757bb89846edfb5195c",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
57
flake.nix
Normal file
57
flake.nix
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
alejandra = {
|
||||||
|
url = "github:kamadorueda/alejandra/4.0.0";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
devenv = {
|
||||||
|
url = "github:cachix/devenv";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
rust-overlay = {
|
||||||
|
url = "github:oxalica/rust-overlay";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
devenv-root = {
|
||||||
|
url = "file+file:///dev/null";
|
||||||
|
flake = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
nixConfig = {
|
||||||
|
extra-trusted-public-keys = [
|
||||||
|
"devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw="
|
||||||
|
];
|
||||||
|
extra-substituters = [
|
||||||
|
"https://devenv.cachix.org"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = {
|
||||||
|
self,
|
||||||
|
nixpkgs,
|
||||||
|
flake-utils,
|
||||||
|
rust-overlay,
|
||||||
|
alejandra,
|
||||||
|
...
|
||||||
|
} @ inputs:
|
||||||
|
flake-utils.lib.eachDefaultSystem (
|
||||||
|
system: let
|
||||||
|
overlays = [(import rust-overlay)];
|
||||||
|
pkgs = import nixpkgs {inherit system overlays;};
|
||||||
|
rustVersion = pkgs.rust-bin.stable.latest.default;
|
||||||
|
rustPlatform = pkgs.makeRustPlatform {
|
||||||
|
cargo = rustVersion;
|
||||||
|
rustc = rustVersion;
|
||||||
|
};
|
||||||
|
in {
|
||||||
|
formatter = alejandra.defaultPackage.${system};
|
||||||
|
packages = import ./nix/package.nix {inherit pkgs rustPlatform;};
|
||||||
|
devShell = import ./nix/shell.nix {
|
||||||
|
inherit inputs pkgs self rustVersion system;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
48
justfile
Normal file
48
justfile
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
default: run
|
||||||
|
|
||||||
|
run:
|
||||||
|
cargo run
|
||||||
|
|
||||||
|
run-release:
|
||||||
|
cargo run --release
|
||||||
|
|
||||||
|
format:
|
||||||
|
cargo fmt --all
|
||||||
|
|
||||||
|
format-check:
|
||||||
|
cargo fmt --check --all
|
||||||
|
|
||||||
|
audit:
|
||||||
|
cargo deny check
|
||||||
|
|
||||||
|
build:
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
build-release:
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
lint:
|
||||||
|
cargo clippy --all-targets
|
||||||
|
|
||||||
|
release-build:
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
release-run:
|
||||||
|
cargo run --release
|
||||||
|
|
||||||
|
test:
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
coverage:
|
||||||
|
mkdir -p coverage
|
||||||
|
cargo tarpaulin --config .tarpaulin.local.toml
|
||||||
|
|
||||||
|
coverage-ci:
|
||||||
|
mkdir -p coverage
|
||||||
|
cargo tarpaulin --config .tarpaulin.ci.toml
|
||||||
|
|
||||||
|
check-all: format-check lint coverage audit
|
||||||
|
|
||||||
|
## Local Variables:
|
||||||
|
## mode: makefile
|
||||||
|
## End:
|
||||||
21
nix/package.nix
Normal file
21
nix/package.nix
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
pkgs,
|
||||||
|
rustPlatform,
|
||||||
|
...
|
||||||
|
}: let
|
||||||
|
cargoToml = builtins.fromTOML (builtins.readFile ../Cargo.toml);
|
||||||
|
name = cargoToml.package.name;
|
||||||
|
version = cargoToml.package.version;
|
||||||
|
rustBuild = rustPlatform.buildRustPackage {
|
||||||
|
pname = name;
|
||||||
|
inherit version;
|
||||||
|
src = ../.;
|
||||||
|
cargoLock.lockFile = ../Cargo.lock;
|
||||||
|
};
|
||||||
|
settingsDir = pkgs.runCommand "settings" {} ''
|
||||||
|
mkdir -p $out/settings
|
||||||
|
cp ${../settings}/*.yaml $out/settings/
|
||||||
|
'';
|
||||||
|
in {
|
||||||
|
jj-mcp = rustBuild;
|
||||||
|
}
|
||||||
11
nix/rust-version.nix
Normal file
11
nix/rust-version.nix
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
rust-overlay,
|
||||||
|
inputs,
|
||||||
|
system,
|
||||||
|
...
|
||||||
|
}: let
|
||||||
|
overlays = [(import rust-overlay)];
|
||||||
|
in rec {
|
||||||
|
pkgs = import inputs.nixpkgs {inherit system overlays;};
|
||||||
|
version = pkgs.rust-bin.stable.latest.default;
|
||||||
|
}
|
||||||
49
nix/shell.nix
Normal file
49
nix/shell.nix
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
inputs,
|
||||||
|
pkgs,
|
||||||
|
self,
|
||||||
|
rustVersion,
|
||||||
|
system,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
inputs.devenv.lib.mkShell {
|
||||||
|
inherit inputs pkgs;
|
||||||
|
modules = [
|
||||||
|
{
|
||||||
|
packages = with pkgs; [
|
||||||
|
(rustVersion.override {
|
||||||
|
extensions = [
|
||||||
|
"clippy"
|
||||||
|
"rust-src"
|
||||||
|
"rust-analyzer"
|
||||||
|
"rustfmt"
|
||||||
|
];
|
||||||
|
})
|
||||||
|
bacon
|
||||||
|
cargo-deny
|
||||||
|
cargo-edit
|
||||||
|
cargo-shuttle
|
||||||
|
cargo-tarpaulin
|
||||||
|
just
|
||||||
|
marksman # Markdown LSP server
|
||||||
|
tombi # TOML LSP server
|
||||||
|
];
|
||||||
|
|
||||||
|
processes.run.exec = "bacon run";
|
||||||
|
|
||||||
|
enterShell = ''
|
||||||
|
echo "🦀 Rust MCP development environment loaded!"
|
||||||
|
echo "📦 Rust version: $(rustc --version)"
|
||||||
|
echo "📦 Cargo version: $(cargo --version)"
|
||||||
|
echo ""
|
||||||
|
echo "Available tools:"
|
||||||
|
echo " - rust-analyzer (LSP)"
|
||||||
|
echo " - clippy (linter)"
|
||||||
|
echo " - rustfmt (formatter)"
|
||||||
|
echo " - bacon (continuous testing/linting)"
|
||||||
|
echo " - cargo-deny (dependency checker)"
|
||||||
|
echo " - cargo-tarpaulin (code coverage)"
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
8
settings/base.yaml
Normal file
8
settings/base.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
application:
|
||||||
|
port: 3100
|
||||||
|
version: "0.1.0"
|
||||||
|
|
||||||
|
rate_limit:
|
||||||
|
enabled: true
|
||||||
|
burst_size: 10
|
||||||
|
per_seconds: 60
|
||||||
8
settings/development.yaml
Normal file
8
settings/development.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
frontend_url: http://localhost:3000
|
||||||
|
debug: true
|
||||||
|
|
||||||
|
application:
|
||||||
|
protocol: http
|
||||||
|
host: 127.0.0.1
|
||||||
|
base_url: http://127.0.0.1:3100
|
||||||
|
name: "sta-dev"
|
||||||
8
settings/production.yaml
Normal file
8
settings/production.yaml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
debug: false
|
||||||
|
frontend_url: ""
|
||||||
|
|
||||||
|
application:
|
||||||
|
name: "sta-prod"
|
||||||
|
protocol: https
|
||||||
|
host: 0.0.0.0
|
||||||
|
base_url: ""
|
||||||
1
sonar-project.properties
Normal file
1
sonar-project.properties
Normal file
@@ -0,0 +1 @@
|
|||||||
|
sonar.projectKey=sta
|
||||||
81
src/lib.rs
Normal file
81
src/lib.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
//! Backend API server for STA
|
||||||
|
//!
|
||||||
|
//! This is a REST API built with the Poem framework that provides:
|
||||||
|
//! - Health check endpoints
|
||||||
|
//! - Application metadata endpoints
|
||||||
|
|
||||||
|
#![deny(clippy::all)]
|
||||||
|
#![deny(clippy::pedantic)]
|
||||||
|
#![deny(clippy::nursery)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
#![allow(clippy::unused_async)]
|
||||||
|
|
||||||
|
/// Custom middleware implementations
|
||||||
|
pub mod middleware;
|
||||||
|
/// API route handlers and endpoints
|
||||||
|
pub mod route;
|
||||||
|
/// Application configuration settings
|
||||||
|
pub mod settings;
|
||||||
|
/// Application startup and server configuration
|
||||||
|
pub mod startup;
|
||||||
|
/// Logging and tracing setup
|
||||||
|
pub mod telemetry;
|
||||||
|
|
||||||
|
type MaybeListener = Option<poem::listener::TcpListener<String>>;
|
||||||
|
|
||||||
|
fn prepare(listener: MaybeListener) -> startup::Application {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let settings = settings::Settings::new().expect("Failed to read settings");
|
||||||
|
if !cfg!(test) {
|
||||||
|
let subscriber = telemetry::get_subscriber(settings.debug);
|
||||||
|
telemetry::init_subscriber(subscriber);
|
||||||
|
}
|
||||||
|
tracing::event!(
|
||||||
|
target: "backend",
|
||||||
|
tracing::Level::DEBUG,
|
||||||
|
"Using these settings: {:?}",
|
||||||
|
settings
|
||||||
|
);
|
||||||
|
let application = startup::Application::build(settings, listener);
|
||||||
|
tracing::event!(
|
||||||
|
target: "backend",
|
||||||
|
tracing::Level::INFO,
|
||||||
|
"Listening on http://{}:{}/",
|
||||||
|
application.host(),
|
||||||
|
application.port()
|
||||||
|
);
|
||||||
|
tracing::event!(
|
||||||
|
target: "backend",
|
||||||
|
tracing::Level::INFO,
|
||||||
|
"Documentation available at http://{}:{}/",
|
||||||
|
application.host(),
|
||||||
|
application.port()
|
||||||
|
);
|
||||||
|
application
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the application with the specified TCP listener.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a `std::io::Error` if the server fails to start or encounters
|
||||||
|
/// an I/O error during runtime (e.g., port already in use, network issues).
|
||||||
|
#[cfg(not(tarpaulin_include))]
|
||||||
|
pub async fn run(listener: MaybeListener) -> Result<(), std::io::Error> {
|
||||||
|
let application = prepare(listener);
|
||||||
|
application.make_app().run().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn make_random_tcp_listener() -> poem::listener::TcpListener<String> {
|
||||||
|
let tcp_listener =
|
||||||
|
std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind a random TCP listener");
|
||||||
|
let port = tcp_listener.local_addr().unwrap().port();
|
||||||
|
poem::listener::TcpListener::bind(format!("127.0.0.1:{port}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn get_test_app() -> startup::App {
|
||||||
|
let tcp_listener = make_random_tcp_listener();
|
||||||
|
prepare(Some(tcp_listener)).make_app().into()
|
||||||
|
}
|
||||||
7
src/main.rs
Normal file
7
src/main.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//! Backend server entry point.
|
||||||
|
|
||||||
|
#[cfg(not(tarpaulin_include))]
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), std::io::Error> {
|
||||||
|
sta::run(None).await
|
||||||
|
}
|
||||||
5
src/middleware/mod.rs
Normal file
5
src/middleware/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//! Custom middleware for the application.
|
||||||
|
//!
|
||||||
|
//! This module contains custom middleware implementations including rate limiting.
|
||||||
|
|
||||||
|
pub mod rate_limit;
|
||||||
208
src/middleware/rate_limit.rs
Normal file
208
src/middleware/rate_limit.rs
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
//! Rate limiting middleware using the governor crate.
|
||||||
|
//!
|
||||||
|
//! This middleware implements per-IP rate limiting using the Generic Cell Rate
|
||||||
|
//! Algorithm (GCRA) via the governor crate. It stores rate limiters in memory
|
||||||
|
//! without requiring external dependencies like Redis.
|
||||||
|
|
||||||
|
use std::{net::IpAddr, num::NonZeroU32, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use governor::{
|
||||||
|
Quota, RateLimiter,
|
||||||
|
clock::DefaultClock,
|
||||||
|
state::{InMemoryState, NotKeyed},
|
||||||
|
};
|
||||||
|
use poem::{Endpoint, Error, IntoResponse, Middleware, Request, Response, Result};
|
||||||
|
|
||||||
|
/// Rate limiting configuration.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RateLimitConfig {
|
||||||
|
/// Maximum number of requests allowed in the time window (burst size).
|
||||||
|
pub burst_size: u32,
|
||||||
|
/// Time window in seconds for rate limiting.
|
||||||
|
pub per_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimitConfig {
|
||||||
|
/// Creates a new rate limit configuration.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `burst_size` - Maximum number of requests allowed in the time window
|
||||||
|
/// * `per_seconds` - Time window in seconds
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(burst_size: u32, per_seconds: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
burst_size,
|
||||||
|
per_seconds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a rate limiter from this configuration.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if `burst_size` is zero.
|
||||||
|
#[must_use]
|
||||||
|
pub fn create_limiter(&self) -> RateLimiter<NotKeyed, InMemoryState, DefaultClock> {
|
||||||
|
let quota = Quota::with_period(Duration::from_secs(self.per_seconds))
|
||||||
|
.expect("Failed to create quota")
|
||||||
|
.allow_burst(NonZeroU32::new(self.burst_size).expect("Burst size must be non-zero"));
|
||||||
|
RateLimiter::direct(quota)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RateLimitConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
// Default: 10 requests per second with burst of 20
|
||||||
|
Self::new(20, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Middleware for rate limiting based on IP address.
|
||||||
|
pub struct RateLimit {
|
||||||
|
limiter: Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimit {
|
||||||
|
/// Creates a new rate limiting middleware with the given configuration.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(config: &RateLimitConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
limiter: Arc::new(config.create_limiter()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E: Endpoint> Middleware<E> for RateLimit {
|
||||||
|
type Output = RateLimitEndpoint<E>;
|
||||||
|
|
||||||
|
fn transform(&self, ep: E) -> Self::Output {
|
||||||
|
RateLimitEndpoint {
|
||||||
|
endpoint: ep,
|
||||||
|
limiter: self.limiter.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The endpoint wrapper that performs rate limiting checks.
|
||||||
|
pub struct RateLimitEndpoint<E> {
|
||||||
|
endpoint: E,
|
||||||
|
limiter: Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E: Endpoint> Endpoint for RateLimitEndpoint<E> {
|
||||||
|
type Output = Response;
|
||||||
|
|
||||||
|
async fn call(&self, req: Request) -> Result<Self::Output> {
|
||||||
|
// Check rate limit
|
||||||
|
if self.limiter.check().is_err() {
|
||||||
|
let client_ip = Self::get_client_ip(&req)
|
||||||
|
.map_or_else(|| "unknown".to_string(), |ip| ip.to_string());
|
||||||
|
|
||||||
|
tracing::event!(
|
||||||
|
target: "backend::middleware::rate_limit",
|
||||||
|
tracing::Level::WARN,
|
||||||
|
client_ip = %client_ip,
|
||||||
|
"Rate limit exceeded"
|
||||||
|
);
|
||||||
|
|
||||||
|
return Err(Error::from_status(
|
||||||
|
poem::http::StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the request
|
||||||
|
let response = self.endpoint.call(req).await;
|
||||||
|
response.map(IntoResponse::into_response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E> RateLimitEndpoint<E> {
|
||||||
|
/// Extracts the client IP address from the request.
|
||||||
|
fn get_client_ip(req: &Request) -> Option<IpAddr> {
|
||||||
|
req.remote_addr()
|
||||||
|
.as_socket_addr()
|
||||||
|
.map(std::net::SocketAddr::ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limit_config_new() {
|
||||||
|
let config = RateLimitConfig::new(10, 60);
|
||||||
|
assert_eq!(config.burst_size, 10);
|
||||||
|
assert_eq!(config.per_seconds, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limit_config_default() {
|
||||||
|
let config = RateLimitConfig::default();
|
||||||
|
assert_eq!(config.burst_size, 20);
|
||||||
|
assert_eq!(config.per_seconds, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limit_config_creates_limiter() {
|
||||||
|
let config = RateLimitConfig::new(5, 1);
|
||||||
|
let limiter = config.create_limiter();
|
||||||
|
|
||||||
|
// First 5 requests should succeed
|
||||||
|
for _ in 0..5 {
|
||||||
|
assert!(limiter.check().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6th request should fail
|
||||||
|
assert!(limiter.check().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rate_limit_middleware_allows_within_limit() {
|
||||||
|
use poem::{EndpointExt, Route, handler, test::TestClient};
|
||||||
|
|
||||||
|
#[handler]
|
||||||
|
async fn index() -> String {
|
||||||
|
"Hello".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = RateLimitConfig::new(5, 60);
|
||||||
|
let app = Route::new()
|
||||||
|
.at("/", poem::get(index))
|
||||||
|
.with(RateLimit::new(&config));
|
||||||
|
let cli = TestClient::new(app);
|
||||||
|
|
||||||
|
// First 5 requests should succeed
|
||||||
|
for _ in 0..5 {
|
||||||
|
let response = cli.get("/").send().await;
|
||||||
|
response.assert_status_is_ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rate_limit_middleware_blocks_over_limit() {
|
||||||
|
use poem::{EndpointExt, Route, handler, test::TestClient};
|
||||||
|
|
||||||
|
#[handler]
|
||||||
|
async fn index() -> String {
|
||||||
|
"Hello".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = RateLimitConfig::new(3, 60);
|
||||||
|
let app = Route::new()
|
||||||
|
.at("/", poem::get(index))
|
||||||
|
.with(RateLimit::new(&config));
|
||||||
|
let cli = TestClient::new(app);
|
||||||
|
|
||||||
|
// First 3 requests should succeed
|
||||||
|
for _ in 0..3 {
|
||||||
|
let response = cli.get("/").send().await;
|
||||||
|
response.assert_status_is_ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4th request should be rate limited
|
||||||
|
let response = cli.get("/").send().await;
|
||||||
|
response.assert_status(poem::http::StatusCode::TOO_MANY_REQUESTS);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/route/health.rs
Normal file
38
src/route/health.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//! Health check endpoint for monitoring service availability.
|
||||||
|
|
||||||
|
use poem_openapi::{ApiResponse, OpenApi};
|
||||||
|
|
||||||
|
use super::ApiCategory;
|
||||||
|
|
||||||
|
#[derive(ApiResponse)]
|
||||||
|
enum HealthResponse {
|
||||||
|
/// Success
|
||||||
|
#[oai(status = 200)]
|
||||||
|
Ok,
|
||||||
|
/// Too Many Requests - rate limit exceeded
|
||||||
|
#[oai(status = 429)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
TooManyRequests,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Health check API for monitoring service availability.
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
pub struct HealthApi;
|
||||||
|
|
||||||
|
#[OpenApi(tag = "ApiCategory::Health")]
|
||||||
|
impl HealthApi {
|
||||||
|
#[oai(path = "/health", method = "get")]
|
||||||
|
async fn ping(&self) -> HealthResponse {
|
||||||
|
tracing::event!(target: "backend::health", tracing::Level::DEBUG, "Accessing health-check endpoint");
|
||||||
|
HealthResponse::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn health_check_works() {
|
||||||
|
let app = crate::get_test_app();
|
||||||
|
let cli = poem::test::TestClient::new(app);
|
||||||
|
let resp = cli.get("/api/health").send().await;
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
resp.assert_text("").await;
|
||||||
|
}
|
||||||
86
src/route/meta.rs
Normal file
86
src/route/meta.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
//! Application metadata endpoint for retrieving version and name information.
|
||||||
|
|
||||||
|
use poem::Result;
|
||||||
|
use poem_openapi::{ApiResponse, Object, OpenApi, payload::Json};
|
||||||
|
|
||||||
|
use super::ApiCategory;
|
||||||
|
use crate::settings::ApplicationSettings;
|
||||||
|
|
||||||
|
#[derive(Object, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
struct Meta {
|
||||||
|
version: String,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&MetaApi> for Meta {
|
||||||
|
fn from(value: &MetaApi) -> Self {
|
||||||
|
let version = value.version.clone();
|
||||||
|
let name = value.name.clone();
|
||||||
|
Self { version, name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(ApiResponse)]
|
||||||
|
enum MetaResponse {
|
||||||
|
/// Success
|
||||||
|
#[oai(status = 200)]
|
||||||
|
Meta(Json<Meta>),
|
||||||
|
/// Too Many Requests - rate limit exceeded
|
||||||
|
#[oai(status = 429)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
TooManyRequests,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API for retrieving application metadata (name and version).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MetaApi {
|
||||||
|
name: String,
|
||||||
|
version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&ApplicationSettings> for MetaApi {
|
||||||
|
fn from(value: &ApplicationSettings) -> Self {
|
||||||
|
let name = value.name.clone();
|
||||||
|
let version = value.version.clone();
|
||||||
|
Self { name, version }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OpenApi(tag = "ApiCategory::Meta")]
|
||||||
|
impl MetaApi {
|
||||||
|
#[oai(path = "/meta", method = "get")]
|
||||||
|
async fn meta(&self) -> Result<MetaResponse> {
|
||||||
|
tracing::event!(target: "backend::meta", tracing::Level::DEBUG, "Accessing meta endpoint");
|
||||||
|
Ok(MetaResponse::Meta(Json(self.into())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[tokio::test]
|
||||||
|
async fn meta_endpoint_returns_correct_data() {
|
||||||
|
let app = crate::get_test_app();
|
||||||
|
let cli = poem::test::TestClient::new(app);
|
||||||
|
let resp = cli.get("/api/meta").send().await;
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
|
||||||
|
let json_value: serde_json::Value = resp.json().await.value().deserialize();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
json_value.get("version").is_some(),
|
||||||
|
"Response should have version field"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
json_value.get("name").is_some(),
|
||||||
|
"Response should have name field"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn meta_endpoint_returns_200_status() {
|
||||||
|
let app = crate::get_test_app();
|
||||||
|
let cli = poem::test::TestClient::new(app);
|
||||||
|
let resp = cli.get("/api/meta").send().await;
|
||||||
|
resp.assert_status_is_ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/route/mod.rs
Normal file
37
src/route/mod.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
//! API route handlers for the backend server.
|
||||||
|
//!
|
||||||
|
//! This module contains all the HTTP endpoint handlers organized by functionality:
|
||||||
|
//! - Health checks
|
||||||
|
//! - Application metadata
|
||||||
|
|
||||||
|
use poem_openapi::Tags;
|
||||||
|
|
||||||
|
mod health;
|
||||||
|
mod meta;
|
||||||
|
|
||||||
|
use crate::settings::Settings;
|
||||||
|
|
||||||
|
#[derive(Tags)]
|
||||||
|
enum ApiCategory {
|
||||||
|
Health,
|
||||||
|
Meta,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct Api {
|
||||||
|
health: health::HealthApi,
|
||||||
|
meta: meta::MetaApi,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Settings> for Api {
|
||||||
|
fn from(value: &Settings) -> Self {
|
||||||
|
let health = health::HealthApi;
|
||||||
|
let meta = meta::MetaApi::from(&value.application);
|
||||||
|
Self { health, meta }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Api {
|
||||||
|
pub fn apis(self) -> (health::HealthApi, meta::MetaApi) {
|
||||||
|
(self.health, self.meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
284
src/settings.rs
Normal file
284
src/settings.rs
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
//! Application configuration settings.
|
||||||
|
//!
|
||||||
|
//! This module provides configuration structures that can be loaded from:
|
||||||
|
//! - YAML configuration files (base.yaml and environment-specific files)
|
||||||
|
//! - Environment variables (prefixed with APP__)
|
||||||
|
//!
|
||||||
|
//! Settings include application details, email server configuration, and environment settings.
|
||||||
|
|
||||||
|
/// Application configuration settings.
|
||||||
|
///
|
||||||
|
/// Loads configuration from YAML files and environment variables.
|
||||||
|
#[derive(Debug, serde::Deserialize, Clone, Default)]
|
||||||
|
pub struct Settings {
|
||||||
|
/// Application-specific settings (name, version, host, port, etc.)
|
||||||
|
pub application: ApplicationSettings,
|
||||||
|
/// Debug mode flag
|
||||||
|
pub debug: bool,
|
||||||
|
/// Frontend URL for CORS configuration
|
||||||
|
pub frontend_url: String,
|
||||||
|
/// Rate limiting configuration
|
||||||
|
#[serde(default)]
|
||||||
|
pub rate_limit: RateLimitSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
/// Creates a new `Settings` instance by loading configuration from files and environment variables.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a `config::ConfigError` if:
|
||||||
|
/// - Configuration files cannot be read or parsed
|
||||||
|
/// - Required configuration values are missing
|
||||||
|
/// - Configuration values cannot be deserialized into the expected types
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if:
|
||||||
|
/// - The current directory cannot be determined
|
||||||
|
/// - The `APP_ENVIRONMENT` variable contains an invalid value (not "dev", "development", "prod", or "production")
|
||||||
|
pub fn new() -> Result<Self, config::ConfigError> {
|
||||||
|
let base_path = std::env::current_dir().expect("Failed to determine the current directory");
|
||||||
|
let settings_directory = base_path.join("settings");
|
||||||
|
let environment: Environment = std::env::var("APP_ENVIRONMENT")
|
||||||
|
.unwrap_or_else(|_| "dev".into())
|
||||||
|
.try_into()
|
||||||
|
.expect("Failed to parse APP_ENVIRONMENT");
|
||||||
|
let environment_filename = format!("{environment}.yaml");
|
||||||
|
// Lower = takes precedence
|
||||||
|
let settings = config::Config::builder()
|
||||||
|
.add_source(config::File::from(settings_directory.join("base.yaml")))
|
||||||
|
.add_source(config::File::from(
|
||||||
|
settings_directory.join(environment_filename),
|
||||||
|
))
|
||||||
|
.add_source(
|
||||||
|
config::Environment::with_prefix("APP")
|
||||||
|
.prefix_separator("__")
|
||||||
|
.separator("__"),
|
||||||
|
)
|
||||||
|
.build()?;
|
||||||
|
settings.try_deserialize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application-specific configuration settings.
|
||||||
|
#[derive(Debug, serde::Deserialize, Clone, Default)]
|
||||||
|
pub struct ApplicationSettings {
|
||||||
|
/// Application name
|
||||||
|
pub name: String,
|
||||||
|
/// Application version
|
||||||
|
pub version: String,
|
||||||
|
/// Port to bind to
|
||||||
|
pub port: u16,
|
||||||
|
/// Host address to bind to
|
||||||
|
pub host: String,
|
||||||
|
/// Base URL of the application
|
||||||
|
pub base_url: String,
|
||||||
|
/// Protocol (http or https)
|
||||||
|
pub protocol: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application environment.
|
||||||
|
#[derive(Debug, PartialEq, Eq, Default)]
|
||||||
|
pub enum Environment {
|
||||||
|
/// Development environment
|
||||||
|
#[default]
|
||||||
|
Development,
|
||||||
|
/// Production environment
|
||||||
|
Production,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Environment {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let self_str = match self {
|
||||||
|
Self::Development => "development",
|
||||||
|
Self::Production => "production",
|
||||||
|
};
|
||||||
|
write!(f, "{self_str}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<String> for Environment {
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||||
|
Self::try_from(value.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&str> for Environment {
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||||
|
match value.to_lowercase().as_str() {
|
||||||
|
"development" | "dev" => Ok(Self::Development),
|
||||||
|
"production" | "prod" => Ok(Self::Production),
|
||||||
|
other => Err(format!(
|
||||||
|
"{other} is not a supported environment. Use either `development` or `production`"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rate limiting configuration.
|
||||||
|
#[derive(Debug, serde::Deserialize, Clone)]
|
||||||
|
pub struct RateLimitSettings {
|
||||||
|
/// Whether rate limiting is enabled
|
||||||
|
#[serde(default = "default_rate_limit_enabled")]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Maximum number of requests allowed in the time window (burst size)
|
||||||
|
#[serde(default = "default_burst_size")]
|
||||||
|
pub burst_size: u32,
|
||||||
|
/// Time window in seconds for rate limiting
|
||||||
|
#[serde(default = "default_per_seconds")]
|
||||||
|
pub per_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RateLimitSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: default_rate_limit_enabled(),
|
||||||
|
burst_size: default_burst_size(),
|
||||||
|
per_seconds: default_per_seconds(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_rate_limit_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_burst_size() -> u32 {
|
||||||
|
100
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn default_per_seconds() -> u64 {
|
||||||
|
60
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_display_development() {
|
||||||
|
let env = Environment::Development;
|
||||||
|
assert_eq!(env.to_string(), "development");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_display_production() {
|
||||||
|
let env = Environment::Production;
|
||||||
|
assert_eq!(env.to_string(), "production");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_from_str_development() {
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("development").unwrap(),
|
||||||
|
Environment::Development
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("dev").unwrap(),
|
||||||
|
Environment::Development
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("Development").unwrap(),
|
||||||
|
Environment::Development
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("DEV").unwrap(),
|
||||||
|
Environment::Development
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_from_str_production() {
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("production").unwrap(),
|
||||||
|
Environment::Production
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("prod").unwrap(),
|
||||||
|
Environment::Production
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("Production").unwrap(),
|
||||||
|
Environment::Production
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("PROD").unwrap(),
|
||||||
|
Environment::Production
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_from_str_invalid() {
|
||||||
|
let result = Environment::try_from("invalid");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("not a supported environment"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_from_string_development() {
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("development".to_string()).unwrap(),
|
||||||
|
Environment::Development
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_from_string_production() {
|
||||||
|
assert_eq!(
|
||||||
|
Environment::try_from("production".to_string()).unwrap(),
|
||||||
|
Environment::Production
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_from_string_invalid() {
|
||||||
|
let result = Environment::try_from("invalid".to_string());
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn environment_default_is_development() {
|
||||||
|
let env = Environment::default();
|
||||||
|
assert_eq!(env, Environment::Development);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limit_settings_default() {
|
||||||
|
let settings = RateLimitSettings::default();
|
||||||
|
assert!(settings.enabled);
|
||||||
|
assert_eq!(settings.burst_size, 100);
|
||||||
|
assert_eq!(settings.per_seconds, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limit_settings_deserialize_full() {
|
||||||
|
let json = r#"{"enabled": true, "burst_size": 50, "per_seconds": 30}"#;
|
||||||
|
let settings: RateLimitSettings = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(settings.enabled);
|
||||||
|
assert_eq!(settings.burst_size, 50);
|
||||||
|
assert_eq!(settings.per_seconds, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limit_settings_deserialize_partial() {
|
||||||
|
let json = r#"{"enabled": false}"#;
|
||||||
|
let settings: RateLimitSettings = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(!settings.enabled);
|
||||||
|
assert_eq!(settings.burst_size, 100); // default
|
||||||
|
assert_eq!(settings.per_seconds, 60); // default
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_limit_settings_deserialize_empty() {
|
||||||
|
let json = "{}";
|
||||||
|
let settings: RateLimitSettings = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(settings.enabled); // default
|
||||||
|
assert_eq!(settings.burst_size, 100); // default
|
||||||
|
assert_eq!(settings.per_seconds, 60); // default
|
||||||
|
}
|
||||||
|
}
|
||||||
227
src/startup.rs
Normal file
227
src/startup.rs
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
//! Application startup and server configuration.
|
||||||
|
//!
|
||||||
|
//! This module handles:
|
||||||
|
//! - Building the application with routes and middleware
|
||||||
|
//! - Setting up the OpenAPI service and Swagger UI
|
||||||
|
//! - Configuring CORS
|
||||||
|
//! - Starting the HTTP server
|
||||||
|
|
||||||
|
use poem::middleware::{AddDataEndpoint, Cors, CorsEndpoint};
|
||||||
|
use poem::{EndpointExt, Route};
|
||||||
|
use poem_openapi::OpenApiService;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
middleware::rate_limit::{RateLimit, RateLimitConfig},
|
||||||
|
route::Api,
|
||||||
|
settings::Settings,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::middleware::rate_limit::RateLimitEndpoint;
|
||||||
|
|
||||||
|
type Server = poem::Server<poem::listener::TcpListener<String>, std::convert::Infallible>;
|
||||||
|
/// The configured application with rate limiting, CORS, and settings data.
|
||||||
|
pub type App = AddDataEndpoint<CorsEndpoint<RateLimitEndpoint<Route>>, Settings>;
|
||||||
|
|
||||||
|
/// Application builder that holds the server configuration before running.
|
||||||
|
pub struct Application {
|
||||||
|
server: Server,
|
||||||
|
app: poem::Route,
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
settings: Settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fully configured application ready to run.
|
||||||
|
pub struct RunnableApplication {
|
||||||
|
server: Server,
|
||||||
|
app: App,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RunnableApplication {
|
||||||
|
/// Runs the application server.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a `std::io::Error` if the server fails to start or encounters
|
||||||
|
/// an I/O error during runtime (e.g., port already in use, network issues).
|
||||||
|
pub async fn run(self) -> Result<(), std::io::Error> {
|
||||||
|
self.server.run(self.app).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RunnableApplication> for App {
|
||||||
|
fn from(value: RunnableApplication) -> Self {
|
||||||
|
value.app
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Application> for RunnableApplication {
|
||||||
|
fn from(value: Application) -> Self {
|
||||||
|
// Configure rate limiting based on settings
|
||||||
|
let rate_limit_config = if value.settings.rate_limit.enabled {
|
||||||
|
tracing::event!(
|
||||||
|
target: "backend::startup",
|
||||||
|
tracing::Level::INFO,
|
||||||
|
burst_size = value.settings.rate_limit.burst_size,
|
||||||
|
per_seconds = value.settings.rate_limit.per_seconds,
|
||||||
|
"Rate limiting enabled"
|
||||||
|
);
|
||||||
|
RateLimitConfig::new(
|
||||||
|
value.settings.rate_limit.burst_size,
|
||||||
|
value.settings.rate_limit.per_seconds,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
tracing::event!(
|
||||||
|
target: "backend::startup",
|
||||||
|
tracing::Level::INFO,
|
||||||
|
"Rate limiting disabled (using very high limits)"
|
||||||
|
);
|
||||||
|
// Use very high limits to effectively disable rate limiting
|
||||||
|
RateLimitConfig::new(u32::MAX, 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = value
|
||||||
|
.app
|
||||||
|
.with(RateLimit::new(&rate_limit_config))
|
||||||
|
.with(Cors::new())
|
||||||
|
.data(value.settings);
|
||||||
|
|
||||||
|
let server = value.server;
|
||||||
|
Self { server, app }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Application {
|
||||||
|
fn setup_app(settings: &Settings) -> poem::Route {
|
||||||
|
let api_service = OpenApiService::new(
|
||||||
|
Api::from(settings).apis(),
|
||||||
|
settings.application.clone().name,
|
||||||
|
settings.application.clone().version,
|
||||||
|
)
|
||||||
|
.url_prefix("/api");
|
||||||
|
let ui = api_service.swagger_ui();
|
||||||
|
poem::Route::new()
|
||||||
|
.nest("/api", api_service.clone())
|
||||||
|
.nest("/specs", api_service.spec_endpoint_yaml())
|
||||||
|
.nest("/", ui)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_server(
|
||||||
|
settings: &Settings,
|
||||||
|
tcp_listener: Option<poem::listener::TcpListener<String>>,
|
||||||
|
) -> Server {
|
||||||
|
let tcp_listener = tcp_listener.unwrap_or_else(|| {
|
||||||
|
let address = format!(
|
||||||
|
"{}:{}",
|
||||||
|
settings.application.host, settings.application.port
|
||||||
|
);
|
||||||
|
poem::listener::TcpListener::bind(address)
|
||||||
|
});
|
||||||
|
poem::Server::new(tcp_listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a new application with the given settings and optional TCP listener.
|
||||||
|
///
|
||||||
|
/// If no listener is provided, one will be created based on the settings.
|
||||||
|
#[must_use]
|
||||||
|
pub fn build(
|
||||||
|
settings: Settings,
|
||||||
|
tcp_listener: Option<poem::listener::TcpListener<String>>,
|
||||||
|
) -> Self {
|
||||||
|
let port = settings.application.port;
|
||||||
|
let host = settings.application.clone().host;
|
||||||
|
let app = Self::setup_app(&settings);
|
||||||
|
let server = Self::setup_server(&settings, tcp_listener);
|
||||||
|
Self {
|
||||||
|
server,
|
||||||
|
app,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
settings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts the application into a runnable application.
|
||||||
|
#[must_use]
|
||||||
|
pub fn make_app(self) -> RunnableApplication {
|
||||||
|
self.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the host address the application is configured to bind to.
|
||||||
|
#[must_use]
|
||||||
|
pub fn host(&self) -> String {
|
||||||
|
self.host.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the port the application is configured to bind to.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn port(&self) -> u16 {
|
||||||
|
self.port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn create_test_settings() -> Settings {
|
||||||
|
Settings {
|
||||||
|
application: crate::settings::ApplicationSettings {
|
||||||
|
name: "test-app".to_string(),
|
||||||
|
version: "1.0.0".to_string(),
|
||||||
|
port: 8080,
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
base_url: "http://localhost:8080".to_string(),
|
||||||
|
protocol: "http".to_string(),
|
||||||
|
},
|
||||||
|
debug: false,
|
||||||
|
frontend_url: "http://localhost:3000".to_string(),
|
||||||
|
rate_limit: crate::settings::RateLimitSettings {
|
||||||
|
enabled: false,
|
||||||
|
burst_size: 100,
|
||||||
|
per_seconds: 60,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn application_build_and_host() {
|
||||||
|
let settings = create_test_settings();
|
||||||
|
let app = Application::build(settings.clone(), None);
|
||||||
|
assert_eq!(app.host(), settings.application.host);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn application_build_and_port() {
|
||||||
|
let settings = create_test_settings();
|
||||||
|
let app = Application::build(settings, None);
|
||||||
|
assert_eq!(app.port(), 8080);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn application_host_returns_correct_value() {
|
||||||
|
let settings = create_test_settings();
|
||||||
|
let app = Application::build(settings, None);
|
||||||
|
assert_eq!(app.host(), "127.0.0.1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn application_port_returns_correct_value() {
|
||||||
|
let settings = create_test_settings();
|
||||||
|
let app = Application::build(settings, None);
|
||||||
|
assert_eq!(app.port(), 8080);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn application_with_custom_listener() {
|
||||||
|
let settings = create_test_settings();
|
||||||
|
let tcp_listener =
|
||||||
|
std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port");
|
||||||
|
let port = tcp_listener.local_addr().unwrap().port();
|
||||||
|
let listener = poem::listener::TcpListener::bind(format!("127.0.0.1:{port}"));
|
||||||
|
|
||||||
|
let app = Application::build(settings, Some(listener));
|
||||||
|
assert_eq!(app.host(), "127.0.0.1");
|
||||||
|
assert_eq!(app.port(), 8080);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/telemetry.rs
Normal file
69
src/telemetry.rs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
//! Logging and tracing configuration.
|
||||||
|
//!
|
||||||
|
//! This module provides utilities for setting up structured logging using the tracing crate.
|
||||||
|
//! Supports both pretty-printed logs for development and JSON logs for production.
|
||||||
|
|
||||||
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
|
|
||||||
|
/// Creates a tracing subscriber configured for the given debug mode.
|
||||||
|
///
|
||||||
|
/// In debug mode, logs are pretty-printed to stdout.
|
||||||
|
/// In production mode, logs are output as JSON.
|
||||||
|
#[must_use]
|
||||||
|
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 subscriber = tracing_subscriber::Registry::default()
|
||||||
|
.with(env_filter)
|
||||||
|
.with(stdout_log);
|
||||||
|
let json_log = if debug {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(tracing_subscriber::fmt::layer().json())
|
||||||
|
};
|
||||||
|
subscriber.with(json_log)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initializes the global tracing subscriber.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if:
|
||||||
|
/// - A global subscriber has already been set
|
||||||
|
/// - The subscriber cannot be set as the global default
|
||||||
|
pub fn init_subscriber(subscriber: impl tracing::Subscriber + Send + Sync) {
|
||||||
|
tracing::subscriber::set_global_default(subscriber).expect("Failed to set subscriber");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_subscriber_debug_mode() {
|
||||||
|
let subscriber = get_subscriber(true);
|
||||||
|
// If we can create the subscriber without panicking, the test passes
|
||||||
|
// We can't easily inspect the subscriber's internals, but we can verify it's created
|
||||||
|
let _ = subscriber;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_subscriber_production_mode() {
|
||||||
|
let subscriber = get_subscriber(false);
|
||||||
|
// If we can create the subscriber without panicking, the test passes
|
||||||
|
let _ = subscriber;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_subscriber_creates_valid_subscriber() {
|
||||||
|
// Test both debug and non-debug modes create valid subscribers
|
||||||
|
let debug_subscriber = get_subscriber(true);
|
||||||
|
let prod_subscriber = get_subscriber(false);
|
||||||
|
|
||||||
|
// Basic smoke test - if these are created without panicking, they're valid
|
||||||
|
let _ = debug_subscriber;
|
||||||
|
let _ = prod_subscriber;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user