From fa7d1d794206cbb24b4825e2f72a11ee6fa1fc28 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Sat, 18 Oct 2025 11:38:19 -0700 Subject: [PATCH] Use new MaxMind env vars on Labrinth (#4573) * Bring in modrinth-maxmind * integrate modrinth-maxmind into labrinth * Fix CI --- Cargo.lock | 42 ++- Cargo.toml | 7 + apps/labrinth/.env.docker-compose | 1 + apps/labrinth/.env.local | 1 + apps/labrinth/Cargo.toml | 4 +- apps/labrinth/src/lib.rs | 28 +- apps/labrinth/src/main.rs | 5 +- apps/labrinth/src/queue/maxmind.rs | 86 +---- apps/labrinth/tests/common/mod.rs | 6 +- packages/modrinth-maxmind/Cargo.toml | 25 ++ packages/modrinth-maxmind/README.md | 1 + packages/modrinth-maxmind/examples/maxmind.rs | 35 ++ packages/modrinth-maxmind/src/lib.rs | 160 +++++++++ packages/modrinth-util/Cargo.toml | 15 + packages/modrinth-util/README.md | 1 + packages/modrinth-util/src/error.rs | 326 ++++++++++++++++++ packages/modrinth-util/src/lib.rs | 23 ++ 17 files changed, 655 insertions(+), 111 deletions(-) create mode 100644 packages/modrinth-maxmind/Cargo.toml create mode 100644 packages/modrinth-maxmind/README.md create mode 100644 packages/modrinth-maxmind/examples/maxmind.rs create mode 100644 packages/modrinth-maxmind/src/lib.rs create mode 100644 packages/modrinth-util/Cargo.toml create mode 100644 packages/modrinth-util/README.md create mode 100644 packages/modrinth-util/src/error.rs create mode 100644 packages/modrinth-util/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 10b4e70c..85a87329 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2343,6 +2343,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs" version = "6.0.0" @@ -4620,7 +4629,6 @@ dependencies = [ "dotenvy", "either", "eyre", - "flate2", "futures", "futures-util", "hex", @@ -4633,8 +4641,8 @@ dependencies = [ "jemalloc_pprof", "json-patch 4.1.0", "lettre", - "maxminddb", "meilisearch-sdk", + "modrinth-maxmind", "murmur2", "paste", "path-util", @@ -4658,7 +4666,6 @@ dependencies = [ "sha2", "spdx", "sqlx", - "tar", "thiserror 2.0.17", "tikv-jemalloc-ctl", "tikv-jemallocator", @@ -5152,6 +5159,35 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "modrinth-maxmind" +version = "0.0.0" +dependencies = [ + "bytes", + "clap", + "directories", + "eyre", + "flate2", + "maxminddb", + "modrinth-util", + "reqwest", + "tar", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "modrinth-util" +version = "0.0.0" +dependencies = [ + "actix-web", + "derive_more 2.0.1", + "dotenvy", + "eyre", + "serde", +] + [[package]] name = "moka" version = "0.12.11" diff --git a/Cargo.toml b/Cargo.toml index 11de2144..5f677806 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,11 +8,15 @@ members = [ "packages/app-lib", "packages/ariadne", "packages/daedalus", + "packages/modrinth-maxmind", + "packages/modrinth-util", "packages/path-util", ] [workspace.package] edition = "2024" +rust-version = "1.90.0" +repository = "https://github.com/modrinth/code" [workspace.dependencies] actix-cors = "0.7.1" @@ -57,6 +61,7 @@ dashmap = "6.1.0" data-url = "0.3.2" deadpool-redis = "0.22.0" derive_more = "2.0.1" +directories = "6.0.0" dirs = "6.0.0" discord-rich-presence = "1.0.0" dotenv-build = "0.1.1" @@ -102,6 +107,8 @@ lettre = { version = "0.11.19", default-features = false, features = [ ] } maxminddb = "0.26.0" meilisearch-sdk = { version = "0.30.0", default-features = false } +modrinth-maxmind = { path = "packages/modrinth-maxmind" } +modrinth-util = { path = "packages/modrinth-util" } murmur2 = "0.1.0" native-dialog = "0.9.2" notify = { version = "8.2.0", default-features = false } diff --git a/apps/labrinth/.env.docker-compose b/apps/labrinth/.env.docker-compose index 45be8c82..16c1f003 100644 --- a/apps/labrinth/.env.docker-compose +++ b/apps/labrinth/.env.docker-compose @@ -114,6 +114,7 @@ CLICKHOUSE_USER=default CLICKHOUSE_PASSWORD=default CLICKHOUSE_DATABASE=staging_ariadne +MAXMIND_ACCOUNT_ID=none MAXMIND_LICENSE_KEY=none FLAME_ANVIL_URL=none diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local index a644cd54..43aede0e 100644 --- a/apps/labrinth/.env.local +++ b/apps/labrinth/.env.local @@ -115,6 +115,7 @@ CLICKHOUSE_USER=default CLICKHOUSE_PASSWORD=default CLICKHOUSE_DATABASE=staging_ariadne +MAXMIND_ACCOUNT_ID=none MAXMIND_LICENSE_KEY=none FLAME_ANVIL_URL=none diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index 5bcc0ea7..9f3075b9 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -42,7 +42,6 @@ deadpool-redis.workspace = true dotenvy = { workspace = true } either = { workspace = true } eyre = { workspace = true } -flate2 = { workspace = true } futures = { workspace = true } futures-util = { workspace = true } hex = { workspace = true } @@ -69,8 +68,8 @@ image = { workspace = true, features = [ itertools = { workspace = true } json-patch = { workspace = true } lettre = { workspace = true } -maxminddb = { workspace = true } meilisearch-sdk = { workspace = true, features = ["reqwest"] } +modrinth-maxmind = { workspace = true } murmur2 = { workspace = true } paste = { workspace = true } path-util = { workspace = true } @@ -111,7 +110,6 @@ sqlx = { workspace = true, features = [ "rust_decimal", "tls-rustls-aws-lc-rs", ] } -tar = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "sync"] } tokio-stream = { workspace = true } diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index f119f71c..eb90acb7 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -3,6 +3,7 @@ use std::time::Duration; use actix_web::web; use database::redis::RedisPool; +use modrinth_maxmind::MaxMind; use queue::{ analytics::AnalyticsQueue, email::EmailQueue, payouts::PayoutsQueue, session::AuthQueue, socket::ActiveSockets, @@ -49,7 +50,7 @@ pub struct LabrinthConfig { pub redis_pool: RedisPool, pub clickhouse: Client, pub file_host: Arc, - pub maxmind: Arc, + pub maxmind: web::Data, pub scheduler: Arc, pub ip_salt: Pepper, pub search_config: search::SearchConfig, @@ -72,7 +73,7 @@ pub fn app_setup( search_config: search::SearchConfig, clickhouse: &mut Client, file_host: Arc, - maxmind: Arc, + maxmind: MaxMind, stripe_client: stripe::Client, anrok_client: anrok::Client, email_queue: EmailQueue, @@ -218,27 +219,6 @@ pub fn app_setup( } }); - let reader = maxmind.clone(); - { - let reader_ref = reader; - scheduler.run(Duration::from_secs(60 * 60 * 24), move || { - let reader_ref = reader_ref.clone(); - - async move { - info!("Downloading MaxMind GeoLite2 country database"); - let result = reader_ref.index().await; - if let Err(e) = result { - warn!( - "Downloading MaxMind GeoLite2 country database failed: {:?}", - e - ); - } - info!("Done downloading MaxMind GeoLite2 country database"); - } - }); - } - info!("Downloading MaxMind GeoLite2 country database"); - let analytics_queue = Arc::new(AnalyticsQueue::new()); { let client_ref = clickhouse.clone(); @@ -287,7 +267,7 @@ pub fn app_setup( redis_pool, clickhouse: clickhouse.clone(), file_host, - maxmind, + maxmind: web::Data::new(maxmind), scheduler: Arc::new(scheduler), ip_salt, search_config, diff --git a/apps/labrinth/src/main.rs b/apps/labrinth/src/main.rs index d4b8e906..237aa9ff 100644 --- a/apps/labrinth/src/main.rs +++ b/apps/labrinth/src/main.rs @@ -13,7 +13,7 @@ use labrinth::search; use labrinth::util::anrok; use labrinth::util::env::parse_var; use labrinth::util::ratelimit::rate_limit_middleware; -use labrinth::{check_env_vars, clickhouse, database, file_hosting, queue}; +use labrinth::{check_env_vars, clickhouse, database, file_hosting}; use std::ffi::CStr; use std::str::FromStr; use std::sync::Arc; @@ -215,8 +215,7 @@ async fn main() -> std::io::Result<()> { return Ok(()); } - let maxmind_reader = - Arc::new(queue::maxmind::MaxMindIndexer::new().await.unwrap()); + let maxmind_reader = modrinth_maxmind::MaxMind::new().await; let prometheus = PrometheusMetricsBuilder::new("labrinth") .endpoint("/metrics") diff --git a/apps/labrinth/src/queue/maxmind.rs b/apps/labrinth/src/queue/maxmind.rs index bb80fb69..050dd1c0 100644 --- a/apps/labrinth/src/queue/maxmind.rs +++ b/apps/labrinth/src/queue/maxmind.rs @@ -1,87 +1,23 @@ -use flate2::read::GzDecoder; -use maxminddb::geoip2::Country; -use std::io::{Cursor, Read}; +use modrinth_maxmind::{MaxMind, geoip2}; use std::net::Ipv6Addr; -use tar::Archive; -use tokio::sync::RwLock; -use tracing::warn; pub struct MaxMindIndexer { - pub reader: RwLock>>>, + pub maxmind: MaxMind, } impl MaxMindIndexer { - pub async fn new() -> Result { - let reader = MaxMindIndexer::inner_index(false).await.ok().flatten(); - - Ok(MaxMindIndexer { - reader: RwLock::new(reader), - }) - } - - pub async fn index(&self) -> Result<(), reqwest::Error> { - let reader = MaxMindIndexer::inner_index(false).await?; - - if let Some(reader) = reader { - let mut reader_new = self.reader.write().await; - *reader_new = Some(reader); - } - - Ok(()) - } - - async fn inner_index( - should_panic: bool, - ) -> Result>>, reqwest::Error> { - let response = reqwest::get( - format!( - "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key={}&suffix=tar.gz", - dotenvy::var("MAXMIND_LICENSE_KEY").unwrap() - ) - ).await?.bytes().await.unwrap().to_vec(); - - let tarfile = GzDecoder::new(Cursor::new(response)); - let mut archive = Archive::new(tarfile); - - if let Ok(entries) = archive.entries() { - for mut file in entries.flatten() { - if let Ok(path) = file.header().path() - && path.extension().and_then(|x| x.to_str()) == Some("mmdb") - { - let mut buf = Vec::new(); - file.read_to_end(&mut buf).unwrap(); - - let reader = maxminddb::Reader::from_source(buf).unwrap(); - - return Ok(Some(reader)); - } - } - } - - if should_panic { - panic!( - "Unable to download maxmind database- did you get a license key?" - ) - } else { - warn!("Unable to download maxmind database."); - - Ok(None) + pub async fn new() -> Self { + Self { + maxmind: MaxMind::new().await, } } pub async fn query(&self, ip: Ipv6Addr) -> Option { - let maxmind = self.reader.read().await; - - if let Some(ref maxmind) = *maxmind { - maxmind - .lookup::(ip.into()) - .ok() - .flatten() - .and_then(|x| { - x.country.and_then(|x| x.iso_code.map(|x| x.to_string())) - }) - } else { - None - } + let reader = self.maxmind.reader.as_ref()?; + reader + .lookup::(ip.into()) + .ok()? + .and_then(|c| c.country) + .and_then(|c| c.iso_code.map(|s| s.to_string())) } } diff --git a/apps/labrinth/tests/common/mod.rs b/apps/labrinth/tests/common/mod.rs index 84fa4778..35ca4dc6 100644 --- a/apps/labrinth/tests/common/mod.rs +++ b/apps/labrinth/tests/common/mod.rs @@ -1,7 +1,8 @@ use labrinth::queue::email::EmailQueue; use labrinth::util::anrok; -use labrinth::{LabrinthConfig, file_hosting, queue}; +use labrinth::{LabrinthConfig, file_hosting}; use labrinth::{check_env_vars, clickhouse}; +use modrinth_maxmind::MaxMind; use std::sync::Arc; pub mod api_common; @@ -37,8 +38,7 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig { Arc::new(file_hosting::MockHost::new()); let mut clickhouse = clickhouse::init_client().await.unwrap(); - let maxmind_reader = - Arc::new(queue::maxmind::MaxMindIndexer::new().await.unwrap()); + let maxmind_reader = MaxMind::new().await; let stripe_client = stripe::Client::new(dotenvy::var("STRIPE_API_KEY").unwrap()); diff --git a/packages/modrinth-maxmind/Cargo.toml b/packages/modrinth-maxmind/Cargo.toml new file mode 100644 index 00000000..15f87aae --- /dev/null +++ b/packages/modrinth-maxmind/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "modrinth-maxmind" +edition.workspace = true +rust-version.workspace = true +repository.workspace = true + +[dependencies] +bytes = { workspace = true } +directories = { workspace = true } +eyre = { workspace = true } +flate2 = { workspace = true } +maxminddb = { workspace = true } +modrinth-util = { workspace = true } +reqwest = { workspace = true, features = ["json"] } +tar = { workspace = true } +tokio = { workspace = true, features = ["fs"] } +tracing = { workspace = true } + +[dev-dependencies] +clap = { workspace = true, features = ["derive"] } +tokio = { workspace = true, features = ["full"] } +tracing-subscriber = { workspace = true } + +[lints] +workspace = true diff --git a/packages/modrinth-maxmind/README.md b/packages/modrinth-maxmind/README.md new file mode 100644 index 00000000..9884bf55 --- /dev/null +++ b/packages/modrinth-maxmind/README.md @@ -0,0 +1 @@ +Allows opening and reading a MaxMind GeoIP database, for use in an `actix-web` app. diff --git a/packages/modrinth-maxmind/examples/maxmind.rs b/packages/modrinth-maxmind/examples/maxmind.rs new file mode 100644 index 00000000..ab104331 --- /dev/null +++ b/packages/modrinth-maxmind/examples/maxmind.rs @@ -0,0 +1,35 @@ +//! Example/testing binary for checking if a MaxMind database can be loaded from +//! the current environment. + +use std::net::IpAddr; + +use eyre::Result; +use maxminddb::geoip2; +use modrinth_util::Context; +use tracing::info; + +/// Looks up country details for an IP using the MaxMind database +#[derive(Debug, clap::Parser)] +struct Args { + /// IP address to look up + ip: IpAddr, +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = ::parse(); + tracing_subscriber::fmt().init(); + + let maxmind = modrinth_maxmind::init_reader() + .await + .wrap_err("failed to create reader")?; + + let ip = args.ip; + let country = maxmind + .lookup::(ip) + .wrap_err("failed to lookup country")?; + + info!("Country details for {ip:?}:\n{country:#?}"); + + Ok(()) +} diff --git a/packages/modrinth-maxmind/src/lib.rs b/packages/modrinth-maxmind/src/lib.rs new file mode 100644 index 00000000..835538aa --- /dev/null +++ b/packages/modrinth-maxmind/src/lib.rs @@ -0,0 +1,160 @@ +#![doc = include_str!("../README.md")] + +use std::{ + io::{Cursor, Read}, + path::Path, + sync::Arc, +}; + +use flate2::read::GzDecoder; +pub use maxminddb::{self, geoip2}; + +use bytes::Bytes; +use eyre::{Result, bail, eyre}; +use modrinth_util::{Context, env_var}; +use tokio::fs; +use tracing::{debug, info, warn}; + +/// MaxMind GeoIP database reader for use as a `web::Data` parameter. +#[derive(Debug, Clone)] +pub struct MaxMind { + /// Database reader. + /// + /// If the backend was not configured with MaxMind, the reader will not be + /// available. + pub reader: Option>>, +} + +impl MaxMind { + /// Creates a [`MaxMind`] with no reader. + #[must_use] + pub const fn none() -> Self { + Self { reader: None } + } + + /// Attempts to create a [`MaxMind`] with a MaxMind GeoIP database reader. + /// + /// This reads creation and download parameters from environment variables. + /// + /// If the database could not be created or downloaded, this will make a + /// [`MaxMind`] with no reader. + pub async fn new() -> Self { + Self { + reader: init_reader() + .await + .inspect_err(|err| { + warn!("Failed to initialize MaxMind: {err:#}"); + }) + .map(Arc::new) + .ok(), + } + } +} + +/// Creates a [`maxminddb::Reader`] for use in [`MaxMind::reader`]. +/// +/// # Errors +/// +/// Errors if the database is not present, or could not be downloaded (i.e. +/// missing license key). +pub async fn init_reader() -> Result> { + let db = if let Ok(db_path) = env_var("MAXMIND_DB") { + info!("Using MaxMind database at {db_path:?}"); + + fs::read(&db_path) + .await + .map(Bytes::from) + .wrap_err_with(|| { + eyre!("failed to read database from {db_path:?}") + })? + } else { + let account_id = env_var("MAXMIND_ACCOUNT_ID")?; + let license_key = env_var("MAXMIND_LICENSE_KEY")?; + + let dirs = directories::ProjectDirs::from( + "com.modrinth", + "Modrinth", + "modrinth-backend", + ) + .wrap_err("failed to get cache directory")?; + let cache_dir = dirs.cache_dir(); + let db_path = cache_dir.join("geolite.mmdb"); + + match fs::read(&db_path).await { + Ok(db) => { + info!("Using cached MaxMind database at {db_path:?}"); + Bytes::from(db) + } + Err(err) => { + debug!( + "Failed to read MaxMind database from {db_path:?}, will download: {err}" + ); + + let db = download(&account_id, &license_key).await?; + + match write_to_cache(cache_dir, &db_path, &db).await { + Ok(()) => { + info!("Wrote GeoIP database cache to {db_path:?}"); + } + Err(err) => warn!( + "Failed to write GeoIP database cache to {db_path:?}: {err:?}", + ), + } + + info!("Downloaded and cached database"); + db + } + } + }; + + maxminddb::Reader::from_source(db).wrap_err("failed to create reader") +} + +async fn download(account_id: &str, license_key: &str) -> Result { + info!("Downloading MaxMind GeoIP database"); + let db = reqwest::Client::new() + .get("https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz") + .basic_auth(account_id, Some(license_key)) + .send() + .await + .wrap_err("failed to begin downloading GeoIP database")? + .error_for_status() + .wrap_err("failed to download GeoIP database")? + .bytes() + .await + .wrap_err("failed to finish downloading GeoIP database")?; + + let db = GzDecoder::new(Cursor::new(db)); + let mut archive = tar::Archive::new(db); + + let entries = archive.entries().wrap_err("failed to read entries")?; + for entry in entries { + let mut entry = entry.wrap_err("failed to read entry")?; + let Ok(path) = entry.header().path() else { + continue; + }; + if path.extension().and_then(|x| x.to_str()) == Some("mmdb") { + let mut buf = Vec::new(); + entry + .read_to_end(&mut buf) + .wrap_err("failed to read entry")?; + return Ok(Bytes::from(buf)); + } + } + + bail!("no entries in archive"); +} + +async fn write_to_cache( + cache_dir: &Path, + db_path: &Path, + db: &[u8], +) -> Result<()> { + fs::create_dir_all(cache_dir) + .await + .wrap_err("failed to create parent directories")?; + fs::write(db_path, db) + .await + .wrap_err("failed to write to file")?; + Ok(()) +} diff --git a/packages/modrinth-util/Cargo.toml b/packages/modrinth-util/Cargo.toml new file mode 100644 index 00000000..1c453c4c --- /dev/null +++ b/packages/modrinth-util/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "modrinth-util" +edition.workspace = true +rust-version.workspace = true +repository.workspace = true + +[dependencies] +actix-web = { workspace = true } +derive_more = { workspace = true, features = ["display", "error", "from"] } +dotenvy = { workspace = true } +eyre = { workspace = true } +serde = { workspace = true, features = ["derive"] } + +[lints] +workspace = true diff --git a/packages/modrinth-util/README.md b/packages/modrinth-util/README.md new file mode 100644 index 00000000..3b3d3194 --- /dev/null +++ b/packages/modrinth-util/README.md @@ -0,0 +1 @@ +Modrinth services utilities. diff --git a/packages/modrinth-util/src/error.rs b/packages/modrinth-util/src/error.rs new file mode 100644 index 00000000..023104a6 --- /dev/null +++ b/packages/modrinth-util/src/error.rs @@ -0,0 +1,326 @@ +use std::{ + convert::Infallible, + fmt::{Debug, Display}, +}; + +use actix_web::{HttpResponse, ResponseError, http::StatusCode}; +use derive_more::{Display, Error}; +use serde::{Deserialize, Serialize}; + +/// Error when calling an HTTP endpoint. +#[derive(Debug, Display, Error)] +pub enum ApiError { + /// Error occurred on the server side, which the caller has no fault in. + Internal(eyre::Report), + /// Caller made an invalid or malformed request. + Request(eyre::Report), + /// Caller attempted a request which they are not allowed to make. + Auth(eyre::Report), +} + +impl ResponseError for ApiError { + fn status_code(&self) -> StatusCode { + match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::Request(_) => StatusCode::BAD_REQUEST, + Self::Auth(_) => StatusCode::UNAUTHORIZED, + } + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).json(ErrorResponse { + // internal error details are not leaked to the caller + description: match self { + Self::Internal(_) => None, + _ => Some(self.to_string()), + }, + }) + } +} + +/// How an [`ApiError`] is represented when sending over an HTTP request. +#[derive(Debug, Serialize, Deserialize)] +pub struct ErrorResponse { + /// Text description of the error that occurred. + /// + /// [`ApiError::Internal`] errors have their description filtered out, and + /// will hold [`None`]. + pub description: Option, +} + +/// Allows wrapping [`Result`]s and [`Option`]s into [`Result`]s. +#[allow( + clippy::missing_errors_doc, + reason = "this trait's purpose is improving error handling" +)] +pub trait Context: Sized { + /// Maps the error variant into an [`eyre::Report`], creating the message + /// using `f`. + fn wrap_err_with(self, f: impl FnOnce() -> D) -> Result + where + D: Send + Sync + Debug + Display + 'static; + + /// Maps the error variant into an [`eyre::Report`] with the given message. + /// Maps the error variant into an [`eyre::Report`] with the given message. + #[inline] + fn wrap_err(self, msg: D) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_err_with(|| msg) + } + + /// Maps the error variant into an [`ApiError::Internal`] using the closure to create the message. + #[inline] + fn wrap_internal_err_with( + self, + f: impl FnOnce() -> D, + ) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_err_with(f).map_err(ApiError::Internal) + } + + /// Maps the error variant into an [`ApiError::Internal`] with the given message. + #[inline] + fn wrap_internal_err(self, msg: D) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_internal_err_with(|| msg) + } + + /// Maps the error variant into an [`ApiError::Request`] using the closure to create the message. + #[inline] + fn wrap_request_err_with( + self, + f: impl FnOnce() -> D, + ) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_err_with(f).map_err(ApiError::Request) + } + + /// Maps the error variant into an [`ApiError::Request`] with the given message. + #[inline] + fn wrap_request_err(self, msg: D) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_request_err_with(|| msg) + } + + /// Maps the error variant into an [`ApiError::Auth`] using the closure to create the message. + #[inline] + fn wrap_auth_err_with(self, f: impl FnOnce() -> D) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_err_with(f).map_err(ApiError::Auth) + } + + /// Maps the error variant into an [`ApiError::Auth`] with the given message. + #[inline] + fn wrap_auth_err(self, msg: D) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_auth_err_with(|| msg) + } +} + +impl Context for Result +where + Self: eyre::WrapErr, +{ + fn wrap_err_with(self, f: impl FnOnce() -> D) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + eyre::WrapErr::wrap_err_with(self, f) + } +} + +impl Context for Option { + fn wrap_err_with(self, f: impl FnOnce() -> D) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.ok_or_else(|| eyre::Report::msg(f())) + } +} + +// impl Context for Ty where Ty: eyre::WrapErr {} + +// impl Context for Ty where Ty: eyre::OptionExt {} + +// impl Context for Result +// where +// Self: eyre::WrapErr, +// { +// fn wrap_err_with(self, f: impl FnOnce() -> D) -> Result +// where +// D: Send + Sync + Debug + Display + 'static, +// { +// self.map_err(|err| eyre::Report::new(err).wrap_err(f())) +// } +// } + +// impl Context for Option { +// fn wrap_err_with(self, f: impl FnOnce() -> D) -> Result +// where +// D: Send + Sync + Debug + Display + 'static, +// { +// self.ok_or_else(|| eyre::Report::msg(f())) +// } +// } + +#[cfg(test)] +mod tests { + use super::*; + use actix_web::http::StatusCode; + + #[test] + fn test_api_error_display() { + let error = ApiError::Internal(eyre::eyre!("test internal error")); + assert!(error.to_string().contains("test internal error")); + + let error = ApiError::Request(eyre::eyre!("test request error")); + assert!(error.to_string().contains("test request error")); + + let error = ApiError::Auth(eyre::eyre!("test auth error")); + assert!(error.to_string().contains("test auth error")); + } + + #[test] + fn test_api_error_debug() { + let error = ApiError::Internal(eyre::eyre!("test error")); + let debug_str = format!("{error:?}"); + assert!(debug_str.contains("Internal")); + assert!(debug_str.contains("test error")); + } + + #[test] + fn test_response_error_status_codes() { + let internal_error = ApiError::Internal(eyre::eyre!("internal error")); + assert_eq!( + internal_error.status_code(), + StatusCode::INTERNAL_SERVER_ERROR + ); + + let request_error = ApiError::Request(eyre::eyre!("request error")); + assert_eq!(request_error.status_code(), StatusCode::BAD_REQUEST); + + let auth_error = ApiError::Auth(eyre::eyre!("auth error")); + assert_eq!(auth_error.status_code(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn test_response_error_response() { + let error = ApiError::Request(eyre::eyre!("test request error")); + let response = error.error_response(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // Skip the body parsing test as it requires async and is more complex + // The important thing is that the error response is created correctly + } + + #[test] + fn test_context_trait_result() { + let result: Result = Ok(42); + let wrapped = result.wrap_err("context message"); + assert_eq!(wrapped.unwrap(), 42); + + let result: Result = Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "not found", + )); + let wrapped = result.wrap_err("context message"); + assert!(wrapped.is_err()); + assert!(wrapped.unwrap_err().to_string().contains("context message")); + } + + #[test] + fn test_context_trait_option() { + let option: Option = Some(42); + let wrapped = option.wrap_err("context message"); + assert_eq!(wrapped.unwrap(), 42); + + let option: Option = None; + let wrapped = option.wrap_err("context message"); + assert!(wrapped.is_err()); + assert_eq!(wrapped.unwrap_err().to_string(), "context message"); + } + + #[test] + fn test_context_trait_internal_error() { + let result: Result = Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "not found", + )); + let wrapped = result.wrap_internal_err("internal error context"); + + assert!(wrapped.is_err()); + match wrapped.unwrap_err() { + ApiError::Internal(report) => { + assert!(report.to_string().contains("internal error context")); + } + _ => panic!("Expected Internal error"), + } + } + + #[test] + fn test_context_trait_request_error() { + let result: Result = Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "not found", + )); + let wrapped = result.wrap_request_err("request error context"); + + assert!(wrapped.is_err()); + match wrapped.unwrap_err() { + ApiError::Request(report) => { + assert!(report.to_string().contains("request error context")); + } + _ => panic!("Expected Request error"), + } + } + + #[test] + fn test_context_trait_auth_error() { + let result: Result = Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "not found", + )); + let wrapped = result.wrap_auth_err("auth error context"); + + assert!(wrapped.is_err()); + match wrapped.unwrap_err() { + ApiError::Auth(report) => { + assert!(report.to_string().contains("auth error context")); + } + _ => panic!("Expected Auth error"), + } + } + + #[test] + fn test_context_trait_with_closure() { + let result: Result = Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "not found", + )); + let wrapped = + result.wrap_err_with(|| format!("context with {}", "dynamic")); + + assert!(wrapped.is_err()); + assert!( + wrapped + .unwrap_err() + .to_string() + .contains("context with dynamic") + ); + } +} diff --git a/packages/modrinth-util/src/lib.rs b/packages/modrinth-util/src/lib.rs new file mode 100644 index 00000000..ca934079 --- /dev/null +++ b/packages/modrinth-util/src/lib.rs @@ -0,0 +1,23 @@ +#![doc = include_str!("../README.md")] + +mod error; +pub use error::*; + +use eyre::{Result, eyre}; + +/// Fetches an environment variable, possibly loading it using [`dotenvy`]. +/// +/// # Errors +/// +/// Errors if the environment variable is missing or empty, providing a +/// pretty-printed error including the environment variable name. +#[track_caller] +pub fn env_var(key: &str) -> Result { + let value = dotenvy::var(key) + .wrap_err_with(|| eyre!("missing environment variable `{key}`"))?; + if value.is_empty() { + Err(eyre!("environment variable `{key}` is empty")) + } else { + Ok(value) + } +}