Use new MaxMind env vars on Labrinth (#4573)

* Bring in modrinth-maxmind

* integrate modrinth-maxmind into labrinth

* Fix CI
This commit is contained in:
aecsocket
2025-10-18 11:38:19 -07:00
committed by GitHub
parent d1ffed564d
commit fa7d1d7942
17 changed files with 655 additions and 111 deletions

42
Cargo.lock generated
View File

@@ -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"

View File

@@ -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 }

View File

@@ -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

View File

@@ -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

View File

@@ -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 }

View File

@@ -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<dyn file_hosting::FileHost + Send + Sync>,
pub maxmind: Arc<queue::maxmind::MaxMindIndexer>,
pub maxmind: web::Data<MaxMind>,
pub scheduler: Arc<scheduler::Scheduler>,
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<dyn file_hosting::FileHost + Send + Sync>,
maxmind: Arc<queue::maxmind::MaxMindIndexer>,
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,

View File

@@ -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")

View File

@@ -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<Option<maxminddb::Reader<Vec<u8>>>>,
pub maxmind: MaxMind,
}
impl MaxMindIndexer {
pub async fn new() -> Result<Self, reqwest::Error> {
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<Option<maxminddb::Reader<Vec<u8>>>, 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<String> {
let maxmind = self.reader.read().await;
if let Some(ref maxmind) = *maxmind {
maxmind
.lookup::<Country>(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::<geoip2::Country>(ip.into())
.ok()?
.and_then(|c| c.country)
.and_then(|c| c.iso_code.map(|s| s.to_string()))
}
}

View File

@@ -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());

View File

@@ -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

View File

@@ -0,0 +1 @@
Allows opening and reading a MaxMind GeoIP database, for use in an `actix-web` app.

View File

@@ -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 = <Args as clap::Parser>::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::<geoip2::Country>(ip)
.wrap_err("failed to lookup country")?;
info!("Country details for {ip:?}:\n{country:#?}");
Ok(())
}

View File

@@ -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<Arc<maxminddb::Reader<Bytes>>>,
}
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<maxminddb::Reader<Bytes>> {
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<Bytes> {
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(())
}

View File

@@ -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

View File

@@ -0,0 +1 @@
Modrinth services utilities.

View File

@@ -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<String>,
}
/// Allows wrapping [`Result`]s and [`Option`]s into [`Result<T, ApiError>`]s.
#[allow(
clippy::missing_errors_doc,
reason = "this trait's purpose is improving error handling"
)]
pub trait Context<T, E>: Sized {
/// Maps the error variant into an [`eyre::Report`], creating the message
/// using `f`.
fn wrap_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, eyre::Report>
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<D>(self, msg: D) -> Result<T, eyre::Report>
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<D>(
self,
f: impl FnOnce() -> D,
) -> Result<T, ApiError>
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<D>(self, msg: D) -> Result<T, ApiError>
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<D>(
self,
f: impl FnOnce() -> D,
) -> Result<T, ApiError>
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<D>(self, msg: D) -> Result<T, ApiError>
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<D>(self, f: impl FnOnce() -> D) -> Result<T, ApiError>
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<D>(self, msg: D) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_auth_err_with(|| msg)
}
}
impl<T, E> Context<T, E> for Result<T, E>
where
Self: eyre::WrapErr<T, E>,
{
fn wrap_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, eyre::Report>
where
D: Send + Sync + Debug + Display + 'static,
{
eyre::WrapErr::wrap_err_with(self, f)
}
}
impl<T> Context<T, Infallible> for Option<T> {
fn wrap_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, eyre::Report>
where
D: Send + Sync + Debug + Display + 'static,
{
self.ok_or_else(|| eyre::Report::msg(f()))
}
}
// impl<T, E, Ty> Context<T, E> for Ty where Ty: eyre::WrapErr<T, E> {}
// impl<T, Ty> Context<T, Infallible> for Ty where Ty: eyre::OptionExt<T> {}
// impl<T, E> Context<T, E> for Result<T, E>
// where
// Self: eyre::WrapErr<T, E>,
// {
// fn wrap_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, eyre::Report>
// where
// D: Send + Sync + Debug + Display + 'static,
// {
// self.map_err(|err| eyre::Report::new(err).wrap_err(f()))
// }
// }
// impl<T> Context<T, Infallible> for Option<T> {
// fn wrap_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, eyre::Report>
// 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<i32, std::io::Error> = Ok(42);
let wrapped = result.wrap_err("context message");
assert_eq!(wrapped.unwrap(), 42);
let result: Result<i32, std::io::Error> = 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<i32> = Some(42);
let wrapped = option.wrap_err("context message");
assert_eq!(wrapped.unwrap(), 42);
let option: Option<i32> = 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<i32, std::io::Error> = 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<i32, std::io::Error> = 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<i32, std::io::Error> = 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<i32, std::io::Error> = 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")
);
}
}

View File

@@ -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<String> {
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)
}
}