feat: rust project initialization

This commit is contained in:
2025-12-21 18:19:21 +01:00
commit d5a2859b64
28 changed files with 4555 additions and 0 deletions

24
.envrc Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

31
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,8 @@
application:
port: 3100
version: "0.1.0"
rate_limit:
enabled: true
burst_size: 10
per_seconds: 60

View 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
View 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
View File

@@ -0,0 +1 @@
sonar.projectKey=sta

81
src/lib.rs Normal file
View 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
View 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
View File

@@ -0,0 +1,5 @@
//! Custom middleware for the application.
//!
//! This module contains custom middleware implementations including rate limiting.
pub mod rate_limit;

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