chore: bootstrap rust service

This commit is contained in:
2026-06-13 06:05:45 +02:00
commit b84de88585
7 changed files with 2295 additions and 0 deletions

47
.github/workflows/rust.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Rust
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_PROFILE_TEST_DEBUG: line-tables-only
CARGO_INCREMENTAL: 0
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: cargo fmt
run: cargo fmt --all --check
- name: cargo clippy
run: cargo clippy --locked --all-targets -- -D warnings
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: cargo test
run: cargo test --locked

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/target/
.env
.DS_Store
.coding-challenge-plan.md

2111
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

36
Cargo.toml Normal file
View File

@@ -0,0 +1,36 @@
[package]
name = "pokedex-api"
version = "0.1.0"
edition = "2024"
license = "MIT"
publish = false
[dependencies]
axum = { version = "0.8", features = ["json", "macros"] }
opentelemetry = "0.31"
opentelemetry-prometheus-text-exporter = "0.2"
opentelemetry_sdk = { version = "0.31", features = ["metrics"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["request-id", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
http-body-util = "0.1"
wiremock = "0.6"
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
all = { level = "deny", priority = -1 }
pedantic = { level = "deny", priority = -1 }
nursery = { level = "deny", priority = -1 }
cargo = { level = "deny", priority = -1 }
multiple_crate_versions = { level = "allow", priority = 1 }

76
src/config.rs Normal file
View File

@@ -0,0 +1,76 @@
use std::{env, net::SocketAddr, time::Duration};
#[derive(Clone, Debug, PartialEq)]
pub struct AppConfig {
pub bind_addr: SocketAddr,
pub pokeapi_base_url: String,
pub translations_base_url: String,
pub request_timeout: Duration,
pub rate_limit_per_second: f64,
pub rate_limit_burst: u32,
pub service_name: String,
}
impl AppConfig {
pub fn from_env() -> Result<Self, ConfigError> {
Ok(Self {
bind_addr: env_or_parse("BIND_ADDR", default_bind_addr())?,
pokeapi_base_url: env_or_string("POKEAPI_BASE_URL", "https://pokeapi.co/api/v2"),
translations_base_url: env_or_string(
"FUN_TRANSLATIONS_BASE_URL",
"https://funtranslations.mercxry.me",
),
request_timeout: Duration::from_secs(env_or_parse("REQUEST_TIMEOUT_SECONDS", 5)?),
rate_limit_per_second: env_or_parse("RATE_LIMIT_PER_SECOND", 20.0)?,
rate_limit_burst: env_or_parse("RATE_LIMIT_BURST", 40)?,
service_name: env_or_string("OTEL_SERVICE_NAME", env!("CARGO_PKG_NAME")),
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("invalid value for {name}: {source}")]
InvalidValue {
name: &'static str,
source: Box<dyn std::error::Error + Send + Sync>,
},
}
fn env_or_string(name: &'static str, default: &str) -> String {
env::var(name).unwrap_or_else(|_| default.to_owned())
}
fn default_bind_addr() -> SocketAddr {
SocketAddr::from(([0, 0, 0, 0], 5000))
}
fn env_or_parse<T>(name: &'static str, default: T) -> Result<T, ConfigError>
where
T: std::str::FromStr,
T::Err: std::error::Error + Send + Sync + 'static,
{
env::var(name).map_or_else(
|_| Ok(default),
|value| {
value.parse().map_err(|source| ConfigError::InvalidValue {
name,
source: Box::new(source),
})
},
)
}
#[cfg(test)]
mod tests {
use super::AppConfig;
#[test]
fn default_config_is_valid_for_local_development() {
let config = AppConfig::from_env().expect("default env should parse");
assert_eq!(config.bind_addr.port(), 5000);
assert_eq!(config.pokeapi_base_url, "https://pokeapi.co/api/v2");
assert!((config.rate_limit_per_second - 20.0).abs() < f64::EPSILON);
}
}

10
src/lib.rs Normal file
View File

@@ -0,0 +1,10 @@
#![deny(warnings)]
#![deny(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::missing_errors_doc, clippy::multiple_crate_versions)]
pub mod config;
#[must_use]
pub const fn crate_name() -> &'static str {
env!("CARGO_PKG_NAME")
}

11
src/main.rs Normal file
View File

@@ -0,0 +1,11 @@
#![deny(warnings)]
#![deny(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::missing_errors_doc, clippy::multiple_crate_versions)]
use pokedex_api::{config::AppConfig, crate_name};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let _config = AppConfig::from_env()?;
println!("{name} bootstrap complete", name = crate_name());
Ok(())
}