You've already forked AstralRinth
forked from didirus/AstralRinth
Use new MaxMind env vars on Labrinth (#4573)
* Bring in modrinth-maxmind * integrate modrinth-maxmind into labrinth * Fix CI
This commit is contained in:
25
packages/modrinth-maxmind/Cargo.toml
Normal file
25
packages/modrinth-maxmind/Cargo.toml
Normal 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
|
||||
1
packages/modrinth-maxmind/README.md
Normal file
1
packages/modrinth-maxmind/README.md
Normal file
@@ -0,0 +1 @@
|
||||
Allows opening and reading a MaxMind GeoIP database, for use in an `actix-web` app.
|
||||
35
packages/modrinth-maxmind/examples/maxmind.rs
Normal file
35
packages/modrinth-maxmind/examples/maxmind.rs
Normal 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(())
|
||||
}
|
||||
160
packages/modrinth-maxmind/src/lib.rs
Normal file
160
packages/modrinth-maxmind/src/lib.rs
Normal 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(())
|
||||
}
|
||||
15
packages/modrinth-util/Cargo.toml
Normal file
15
packages/modrinth-util/Cargo.toml
Normal 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
|
||||
1
packages/modrinth-util/README.md
Normal file
1
packages/modrinth-util/README.md
Normal file
@@ -0,0 +1 @@
|
||||
Modrinth services utilities.
|
||||
326
packages/modrinth-util/src/error.rs
Normal file
326
packages/modrinth-util/src/error.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
23
packages/modrinth-util/src/lib.rs
Normal file
23
packages/modrinth-util/src/lib.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user