diff --git a/Cargo.lock b/Cargo.lock index 9d1ca82..2980d02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -913,33 +913,6 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" -[[package]] -name = "color-eyre" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55146f5e46f237f7423d74111267d4597b59b0dad0ffaf7303bce9945d843ad5" -dependencies = [ - "backtrace", - "color-spantrace", - "eyre", - "indenter", - "once_cell", - "owo-colors", - "tracing-error", -] - -[[package]] -name = "color-spantrace" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2" -dependencies = [ - "once_cell", - "owo-colors", - "tracing-core", - "tracing-error", -] - [[package]] name = "colorchoice" version = "1.0.3" @@ -1720,16 +1693,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "eyre" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" -dependencies = [ - "indenter", - "once_cell", -] - [[package]] name = "fancy-regex" version = "0.14.0" @@ -2776,12 +2739,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "indenter" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" - [[package]] name = "indexmap" version = "1.9.3" @@ -4094,12 +4051,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" -[[package]] -name = "owo-colors" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" - [[package]] name = "p256" version = "0.13.2" @@ -4608,7 +4559,7 @@ dependencies = [ "tap", "thiserror 2.0.12", "tracing", - "typed-builder", + "typed-builder 0.20.1", "url", ] @@ -4829,7 +4780,6 @@ dependencies = [ name = "recorder" version = "0.1.0" dependencies = [ - "anyhow", "async-graphql", "async-graphql-axum", "async-stream", @@ -4841,7 +4791,6 @@ dependencies = [ "bytes", "chrono", "clap", - "color-eyre", "cookie", "ctor", "dotenv", @@ -4891,16 +4840,17 @@ dependencies = [ "serde_with", "serde_yaml", "serial_test", + "snafu", "tera", "testcontainers", "testcontainers-modules", - "thiserror 2.0.12", "tokio", "tower", "tower-http", "tracing", "tracing-appender", "tracing-subscriber", + "typed-builder 0.21.0", "url", "uuid", "zune-image", @@ -6051,6 +6001,29 @@ dependencies = [ "serde", ] +[[package]] +name = "snafu" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "223891c85e2a29c3fe8fb900c1fae5e69c2e42415e3177752e8718475efa5019" +dependencies = [ + "futures-core", + "pin-project", + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c3c6b7927ffe7ecaa769ee0e3994da3b8cafc8f444578982c83ecb161af917" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "socket2" version = "0.5.9" @@ -6945,16 +6918,6 @@ dependencies = [ "valuable", ] -[[package]] -name = "tracing-error" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" -dependencies = [ - "tracing", - "tracing-subscriber", -] - [[package]] name = "tracing-log" version = "0.2.0" @@ -7026,7 +6989,16 @@ version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd9d30e3a08026c78f246b173243cf07b3696d274debd26680773b6773c2afc7" dependencies = [ - "typed-builder-macro", + "typed-builder-macro 0.20.1", +] + +[[package]] +name = "typed-builder" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce63bcaf7e9806c206f7d7b9c1f38e0dce8bb165a80af0898161058b19248534" +dependencies = [ + "typed-builder-macro 0.21.0", ] [[package]] @@ -7040,6 +7012,17 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "typed-builder-macro" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d8d828da2a3d759d3519cdf29a5bac49c77d039ad36d0782edadbf9cd5415b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "typenum" version = "1.18.0" diff --git a/apps/recorder/Cargo.toml b/apps/recorder/Cargo.toml index 0a5e46c..640a745 100644 --- a/apps/recorder/Cargo.toml +++ b/apps/recorder/Cargo.toml @@ -22,6 +22,7 @@ testcontainers = [ ] [dependencies] + serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1.42", features = ["macros", "fs", "rt-multi-thread"] } @@ -48,7 +49,6 @@ reqwest = { version = "0.12", default-features = false, features = [ "rustls-tls", "cookies", ] } -thiserror = "2" rss = "2" bytes = "1.9" itertools = "0.14" @@ -83,9 +83,7 @@ testcontainers = { version = "0.23.3", features = [ "reusable-containers", ], optional = true } testcontainers-modules = { version = "0.11.4", optional = true } -color-eyre = "0.6" log = "0.4.22" -anyhow = "1.0.95" bollard = { version = "0.18", optional = true } async-graphql = { version = "7", features = [] } async-graphql-axum = "7" @@ -131,7 +129,8 @@ futures-util = "0.3.31" ipnetwork = "0.21.1" ctor = "0.4.0" librqbit = "8.0.0" - +typed-builder = "0.21.0" +snafu = { version = "0.8.5", features = ["futures"] } [dev-dependencies] serial_test = "3" insta = { version = "1", features = ["redactions", "yaml", "filters"] } diff --git a/apps/recorder/examples/playground.rs b/apps/recorder/examples/playground.rs index 3ece8a2..7da4124 100644 --- a/apps/recorder/examples/playground.rs +++ b/apps/recorder/examples/playground.rs @@ -1,14 +1,7 @@ +use recorder::errors::RResult; // #![allow(unused_imports)] -// use color_eyre::eyre::Context; -// use itertools::Itertools; -// use loco_rs::{ -// app::Hooks, -// boot::{BootResult, StartMode}, -// environment::Environment, -// prelude::AppContext as LocoContext, -// }; // use recorder::{ -// app::{App1, AppContext}, +// app::{AppContext, AppContextTrait}, // errors::RResult, // migrations::Migrator, // models::{ @@ -16,7 +9,7 @@ // subscriptions::{self, SubscriptionCreateFromRssDto}, // }, // }; -// use sea_orm::ColumnTrait; +// use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; // use sea_orm_migration::MigratorTrait; // async fn pull_mikan_bangumi_rss(ctx: &dyn AppContextTrait) -> RResult<()> { @@ -50,19 +43,14 @@ // Ok(()) // } -// async fn init() -> RResult { -// let ctx = loco_rs::cli::playground::().await?; -// let BootResult { -// app_context: ctx, .. -// } = loco_rs::boot::run_app::(&StartMode::ServerOnly, ctx).await?; -// Migrator::up(ctx.db(), None).await?; -// Ok(ctx) -// } - // #[tokio::main] -// async fn main() -> color_eyre::eyre::Result<()> { +// async fn main() -> RResult<()> { // pull_mikan_bangumi_rss(&ctx).await?; // Ok(()) // } -fn main() {} + +#[tokio::main] +async fn main() -> RResult<()> { + Ok(()) +} diff --git a/apps/recorder/src/auth/errors.rs b/apps/recorder/src/auth/errors.rs index 666b291..f5a09ab 100644 --- a/apps/recorder/src/auth/errors.rs +++ b/apps/recorder/src/auth/errors.rs @@ -1,5 +1,3 @@ -use std::fmt; - use async_graphql::dynamic::ResolverContext; use axum::{ Json, @@ -11,72 +9,86 @@ use openidconnect::{ StandardErrorResponse, core::CoreErrorResponseType, }; use serde::{Deserialize, Serialize}; -use thiserror::Error; +use snafu::prelude::*; use crate::{fetch::HttpClientError, models::auth::AuthType}; -#[derive(Debug, Error)] +#[derive(Debug, Snafu)] +#[snafu(visibility(pub(crate)))] pub enum AuthError { - #[error("Not support auth method")] + #[snafu(display("Not support auth method"))] NotSupportAuthMethod { supported: Vec, current: AuthType, }, - #[error("Failed to find auth record")] + #[snafu(display("Failed to find auth record"))] FindAuthRecordError, - #[error("Invalid credentials")] + #[snafu(display("Invalid credentials"))] BasicInvalidCredentials, - #[error(transparent)] - OidcInitError(#[from] jwt_authorizer::error::InitError), - #[error("Invalid oidc provider meta client error: {0}")] - OidcProviderHttpClientError(HttpClientError), - #[error(transparent)] - OidcProviderMetaError(#[from] openidconnect::DiscoveryError), - #[error("Invalid oidc provider URL: {0}")] - OidcProviderUrlError(url::ParseError), - #[error("Invalid oidc redirect URI: {0}")] - OidcRequestRedirectUriError(url::ParseError), - #[error("Oidc request session not found or expired")] + #[snafu(transparent)] + OidcInitError { + source: jwt_authorizer::error::InitError, + }, + #[snafu(display("Invalid oidc provider meta client error: {source}"))] + OidcProviderHttpClientError { source: HttpClientError }, + #[snafu(transparent)] + OidcProviderMetaError { + source: openidconnect::DiscoveryError, + }, + #[snafu(display("Invalid oidc provider URL: {source}"))] + OidcProviderUrlError { source: url::ParseError }, + #[snafu(display("Invalid oidc redirect URI: {source}"))] + OidcRequestRedirectUriError { + #[snafu(source)] + source: url::ParseError, + }, + #[snafu(display("Oidc request session not found or expired"))] OidcCallbackRecordNotFoundOrExpiredError, - #[error("Invalid oidc request callback nonce")] + #[snafu(display("Invalid oidc request callback nonce"))] OidcInvalidNonceError, - #[error("Invalid oidc request callback state")] + #[snafu(display("Invalid oidc request callback state"))] OidcInvalidStateError, - #[error("Invalid oidc request callback code")] + #[snafu(display("Invalid oidc request callback code"))] OidcInvalidCodeError, - #[error(transparent)] - OidcCallbackTokenConfigurationError(#[from] ConfigurationError), - #[error(transparent)] - OidcRequestTokenError( - #[from] RequestTokenError>, - ), - #[error("Invalid oidc id token")] + #[snafu(transparent)] + OidcCallbackTokenConfigurationError { source: ConfigurationError }, + #[snafu(transparent)] + OidcRequestTokenError { + source: RequestTokenError>, + }, + #[snafu(display("Invalid oidc id token"))] OidcInvalidIdTokenError, - #[error("Invalid oidc access token")] + #[snafu(display("Invalid oidc access token"))] OidcInvalidAccessTokenError, - #[error(transparent)] - OidcSignatureVerificationError(#[from] SignatureVerificationError), - #[error(transparent)] - OidcSigningError(#[from] SigningError), - #[error(transparent)] - OidcJwtAuthError(#[from] jwt_authorizer::AuthError), - #[error("Extra scopes {expected} do not match found scopes {found}")] + #[snafu(transparent)] + OidcSignatureVerificationError { source: SignatureVerificationError }, + #[snafu(transparent)] + OidcSigningError { source: SigningError }, + #[snafu(transparent)] + OidcJwtAuthError { source: jwt_authorizer::AuthError }, + #[snafu(display("Extra scopes {expected} do not match found scopes {found}"))] OidcExtraScopesMatchError { expected: String, found: String }, - #[error("Extra claim {key} does not match expected value {expected}, found {found}")] + #[snafu(display("Extra claim {key} does not match expected value {expected}, found {found}"))] OidcExtraClaimMatchError { key: String, expected: String, found: String, }, - #[error("Extra claim {0} missing")] - OidcExtraClaimMissingError(String), - #[error("Audience {0} missing")] - OidcAudMissingError(String), - #[error("Subject missing")] + #[snafu(display("Extra claim {claim} missing"))] + OidcExtraClaimMissingError { claim: String }, + #[snafu(display("Audience {aud} missing"))] + OidcAudMissingError { aud: String }, + #[snafu(display("Subject missing"))] OidcSubMissingError, - #[error(fmt = display_graphql_permission_error)] + #[snafu(display( + "GraphQL permission denied since {context_path}{}{field}{}{column}: {}", + (if field.is_empty() { "" } else { "." }), + (if column.is_empty() { "" } else { "." }), + source.message + ))] GraphQLPermissionError { - inner_error: async_graphql::Error, + #[snafu(source(false))] + source: Box, field: String, column: String, context_path: String, @@ -85,13 +97,13 @@ pub enum AuthError { impl AuthError { pub fn from_graphql_subscribe_id_guard( - inner_error: async_graphql::Error, + source: async_graphql::Error, context: &ResolverContext, field_name: &str, column_name: &str, ) -> AuthError { AuthError::GraphQLPermissionError { - inner_error, + source: Box::new(source), field: field_name.to_string(), column: column_name.to_string(), context_path: context @@ -103,22 +115,6 @@ impl AuthError { } } -fn display_graphql_permission_error( - inner_error: &async_graphql::Error, - field: &String, - column: &String, - context_path: &String, - formatter: &mut fmt::Formatter<'_>, -) -> fmt::Result { - write!( - formatter, - "GraphQL permission denied since {context_path}{}{field}{}{column}: {}", - (if field.is_empty() { "" } else { "." }), - (if column.is_empty() { "" } else { "." }), - inner_error.message - ) -} - #[derive(Clone, Debug, Serialize, Deserialize)] pub struct AuthErrorResponse { pub success: bool, diff --git a/apps/recorder/src/auth/oidc.rs b/apps/recorder/src/auth/oidc.rs index 13ec21f..36b0472 100644 --- a/apps/recorder/src/auth/oidc.rs +++ b/apps/recorder/src/auth/oidc.rs @@ -16,11 +16,12 @@ use openidconnect::{ use sea_orm::DbErr; use serde::{Deserialize, Serialize}; use serde_json::Value; +use snafu::ResultExt; use url::Url; use super::{ config::OidcAuthConfig, - errors::AuthError, + errors::{AuthError, OidcProviderUrlSnafu, OidcRequestRedirectUriSnafu}, service::{AuthServiceTrait, AuthUserInfo}, }; use crate::{app::AppContextTrait, errors::RError, fetch::HttpClient, models::auth::AuthType}; @@ -125,13 +126,13 @@ impl OidcAuthService { redirect_uri: &str, ) -> Result { let provider_metadata = CoreProviderMetadata::discover_async( - IssuerUrl::new(self.config.issuer.clone()).map_err(AuthError::OidcProviderUrlError)?, + IssuerUrl::new(self.config.issuer.clone()).context(OidcProviderUrlSnafu)?, &self.oidc_provider_client, ) .await?; - let redirect_uri = RedirectUrl::new(redirect_uri.to_string()) - .map_err(AuthError::OidcRequestRedirectUriError)?; + let redirect_uri = + RedirectUrl::new(redirect_uri.to_string()).context(OidcRequestRedirectUriSnafu)?; let oidc_client = CoreClient::from_provider_metadata( provider_metadata, @@ -207,7 +208,7 @@ impl OidcAuthService { let request_cache = self.load_authorization_request(&csrf_token).await?; let provider_metadata = CoreProviderMetadata::discover_async( - IssuerUrl::new(self.config.issuer.clone()).map_err(AuthError::OidcProviderUrlError)?, + IssuerUrl::new(self.config.issuer.clone()).context(OidcProviderUrlSnafu)?, &self.oidc_provider_client, ) .await?; @@ -265,9 +266,10 @@ impl AuthServiceTrait for OidcAuthService { request: &mut Parts, ) -> Result { let config = &self.config; - let token = self.api_authorizer.extract_token(&request.headers).ok_or( - AuthError::OidcJwtAuthError(jwt_authorizer::AuthError::MissingToken()), - )?; + let token = self + .api_authorizer + .extract_token(&request.headers) + .ok_or(jwt_authorizer::AuthError::MissingToken())?; let token_data = self.api_authorizer.check_auth(&token).await?; let claims = token_data.claims; @@ -277,7 +279,9 @@ impl AuthServiceTrait for OidcAuthService { return Err(AuthError::OidcSubMissingError); }; if !claims.contains_audience(&config.audience) { - return Err(AuthError::OidcAudMissingError(config.audience.clone())); + return Err(AuthError::OidcAudMissingError { + aud: config.audience.clone(), + }); } if let Some(expected_scopes) = config.extra_scopes.as_ref() { let found_scopes = claims.scopes().collect::>(); @@ -293,7 +297,7 @@ impl AuthServiceTrait for OidcAuthService { } if let Some(key) = config.extra_claim_key.as_ref() { if !claims.has_claim(key) { - return Err(AuthError::OidcExtraClaimMissingError(key.clone())); + return Err(AuthError::OidcExtraClaimMissingError { claim: key.clone() }); } if let Some(value) = config.extra_claim_value.as_ref() { if claims.get_claim(key).is_none_or(|v| &v != value) { @@ -306,9 +310,9 @@ impl AuthServiceTrait for OidcAuthService { } } let subscriber_auth = match crate::models::auth::Model::find_by_pid(ctx, sub).await { - Err(RError::DbError(DbErr::RecordNotFound(..))) => { - crate::models::auth::Model::create_from_oidc(ctx, sub.to_string()).await - } + Err(RError::DbError { + source: DbErr::RecordNotFound(..), + }) => crate::models::auth::Model::create_from_oidc(ctx, sub.to_string()).await, r => r, } .map_err(|_| AuthError::FindAuthRecordError)?; diff --git a/apps/recorder/src/auth/service.rs b/apps/recorder/src/auth/service.rs index 0274c8a..2aa4865 100644 --- a/apps/recorder/src/auth/service.rs +++ b/apps/recorder/src/auth/service.rs @@ -9,11 +9,12 @@ use axum::{ use jwt_authorizer::{JwtAuthorizer, Validation}; use moka::future::Cache; use reqwest::header::HeaderValue; +use snafu::prelude::*; use super::{ AuthConfig, basic::BasicAuthService, - errors::AuthError, + errors::{AuthError, OidcProviderHttpClientSnafu}, oidc::{OidcAuthClaims, OidcAuthService}, }; use crate::{ @@ -59,14 +60,14 @@ pub trait AuthServiceTrait { } pub enum AuthService { - Basic(BasicAuthService), - Oidc(OidcAuthService), + Basic(Box), + Oidc(Box), } impl AuthService { pub async fn from_conf(config: AuthConfig) -> Result { let result = match config { - AuthConfig::Basic(config) => AuthService::Basic(BasicAuthService { config }), + AuthConfig::Basic(config) => AuthService::Basic(Box::new(BasicAuthService { config })), AuthConfig::Oidc(config) => { let validation = Validation::new() .iss(&[&config.issuer]) @@ -78,14 +79,14 @@ impl AuthService { cache_preset: Some(HttpClientCachePresetConfig::RFC7234), ..Default::default() }) - .map_err(AuthError::OidcProviderHttpClientError)?; + .context(OidcProviderHttpClientSnafu)?; let api_authorizer = JwtAuthorizer::::from_oidc(&config.issuer) .validation(validation) .build() .await?; - AuthService::Oidc(OidcAuthService { + AuthService::Oidc(Box::new(OidcAuthService { config, api_authorizer, oidc_provider_client, @@ -93,7 +94,7 @@ impl AuthService { .time_to_live(Duration::from_mins(5)) .name("oidc_request_cache") .build(), - }) + })) } }; Ok(result) diff --git a/apps/recorder/src/bin/main.rs b/apps/recorder/src/bin/main.rs index 7df982e..d2f87a1 100644 --- a/apps/recorder/src/bin/main.rs +++ b/apps/recorder/src/bin/main.rs @@ -1,10 +1,7 @@ -use color_eyre::{self, eyre}; -use recorder::app::AppBuilder; +use recorder::{app::AppBuilder, errors::RResult}; #[tokio::main] -async fn main() -> eyre::Result<()> { - color_eyre::install()?; - +async fn main() -> RResult<()> { let builder = AppBuilder::from_main_cli(None).await?; let app = builder.build().await?; diff --git a/apps/recorder/src/download/error.rs b/apps/recorder/src/download/error.rs deleted file mode 100644 index 6c48d16..0000000 --- a/apps/recorder/src/download/error.rs +++ /dev/null @@ -1,26 +0,0 @@ -use std::{borrow::Cow, time::Duration}; - -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum TorrentDownloadError { - #[error("Invalid mime (expected {expected:?}, got {found:?})")] - InvalidMime { expected: String, found: String }, - #[error("Invalid url schema (expected {expected:?}, got {found:?})")] - InvalidUrlSchema { expected: String, found: String }, - #[error("Invalid url parse: {0:?}")] - InvalidUrlParse(#[from] url::ParseError), - #[error("Invalid url format: {reason}")] - InvalidUrlFormat { reason: Cow<'static, str> }, - #[error("QBit api error: {0:?}")] - QBitAPIError(#[from] qbit_rs::Error), - #[error("Timeout error ({action} timeouts out of {timeout:?})")] - TimeoutError { - action: Cow<'static, str>, - timeout: Duration, - }, - #[error("Invalid torrent file format")] - InvalidTorrentFileFormat, - #[error("Invalid magnet file format (url = {url})")] - InvalidMagnetFormat { url: String }, -} diff --git a/apps/recorder/src/download/rqbit/mod.rs b/apps/recorder/src/download/rqbit/mod.rs deleted file mode 100644 index 8143b59..0000000 --- a/apps/recorder/src/download/rqbit/mod.rs +++ /dev/null @@ -1 +0,0 @@ -use librqbit::TorrentMetadata; diff --git a/apps/recorder/src/download/core.rs b/apps/recorder/src/downloader/core.rs similarity index 78% rename from apps/recorder/src/download/core.rs rename to apps/recorder/src/downloader/core.rs index 6964067..b32f837 100644 --- a/apps/recorder/src/download/core.rs +++ b/apps/recorder/src/downloader/core.rs @@ -10,9 +10,10 @@ use librqbit_core::{ use quirks_path::{Path, PathBuf}; use regex::Regex; use serde::{Deserialize, Serialize}; +use snafu::prelude::*; use url::Url; -use super::{QbitTorrent, QbitTorrentContent, TorrentDownloadError}; +use super::{DownloaderError, QbitTorrent, QbitTorrentContent, errors::DownloadFetchSnafu}; use crate::fetch::{HttpClientTrait, fetch_bytes}; pub const BITTORRENT_MIME_TYPE: &str = "application/x-bittorrent"; @@ -57,10 +58,7 @@ pub enum TorrentSource { } impl TorrentSource { - pub async fn parse( - client: &H, - url: &str, - ) -> color_eyre::eyre::Result { + pub async fn parse(client: &H, url: &str) -> Result { let url = Url::parse(url)?; let source = if url.scheme() == MAGNET_SCHEMA { TorrentSource::from_magnet_url(url)? @@ -75,22 +73,25 @@ impl TorrentSource { ) { TorrentSource::from_torrent_url(url, match_hash.as_str().to_string())? } else { - let contents = fetch_bytes(client, url).await?; + let contents = fetch_bytes(client, url) + .await + .boxed() + .context(DownloadFetchSnafu)?; TorrentSource::from_torrent_file(contents.to_vec(), Some(basename.to_string()))? } } else { - let contents = fetch_bytes(client, url).await?; + let contents = fetch_bytes(client, url) + .await + .boxed() + .context(DownloadFetchSnafu)?; TorrentSource::from_torrent_file(contents.to_vec(), None)? }; Ok(source) } - pub fn from_torrent_file( - file: Vec, - name: Option, - ) -> color_eyre::eyre::Result { - let torrent: TorrentMetaV1Owned = torrent_from_bytes(&file) - .map_err(|_| TorrentDownloadError::InvalidTorrentFileFormat)?; + pub fn from_torrent_file(file: Vec, name: Option) -> Result { + let torrent: TorrentMetaV1Owned = + torrent_from_bytes(&file).map_err(|_| DownloaderError::TorrentFileFormatError)?; let hash = torrent.info_hash.as_string(); Ok(TorrentSource::TorrentFile { torrent: file, @@ -99,23 +100,21 @@ impl TorrentSource { }) } - pub fn from_magnet_url(url: Url) -> color_eyre::eyre::Result { + pub fn from_magnet_url(url: Url) -> Result { if url.scheme() != MAGNET_SCHEMA { - Err(TorrentDownloadError::InvalidUrlSchema { + Err(DownloaderError::DownloadSchemaError { found: url.scheme().to_string(), expected: MAGNET_SCHEMA.to_string(), - } - .into()) + }) } else { - let magnet = Magnet::parse(url.as_str()).map_err(|_| { - TorrentDownloadError::InvalidMagnetFormat { + let magnet = + Magnet::parse(url.as_str()).map_err(|_| DownloaderError::MagnetFormatError { url: url.as_str().to_string(), - } - })?; + })?; let hash = magnet .as_id20() - .ok_or_else(|| TorrentDownloadError::InvalidMagnetFormat { + .ok_or_else(|| DownloaderError::MagnetFormatError { url: url.as_str().to_string(), })? .as_string(); @@ -123,7 +122,7 @@ impl TorrentSource { } } - pub fn from_torrent_url(url: Url, hash: String) -> color_eyre::eyre::Result { + pub fn from_torrent_url(url: Url, hash: String) -> Result { Ok(TorrentSource::TorrentUrl { url, hash }) } @@ -252,47 +251,47 @@ pub trait TorrentDownloader { status_filter: TorrentFilter, category: Option, tag: Option, - ) -> color_eyre::eyre::Result>; + ) -> Result, DownloaderError>; async fn add_torrents( &self, source: TorrentSource, save_path: String, category: Option<&str>, - ) -> color_eyre::eyre::Result<()>; + ) -> Result<(), DownloaderError>; - async fn delete_torrents(&self, hashes: Vec) -> color_eyre::eyre::Result<()>; + async fn delete_torrents(&self, hashes: Vec) -> Result<(), DownloaderError>; async fn rename_torrent_file( &self, hash: &str, old_path: &str, new_path: &str, - ) -> color_eyre::eyre::Result<()>; + ) -> Result<(), DownloaderError>; async fn move_torrents( &self, hashes: Vec, new_path: &str, - ) -> color_eyre::eyre::Result<()>; + ) -> Result<(), DownloaderError>; - async fn get_torrent_path(&self, hashes: String) -> color_eyre::eyre::Result>; + async fn get_torrent_path(&self, hashes: String) -> Result, DownloaderError>; - async fn check_connection(&self) -> color_eyre::eyre::Result<()>; + async fn check_connection(&self) -> Result<(), DownloaderError>; async fn set_torrents_category( &self, hashes: Vec, category: &str, - ) -> color_eyre::eyre::Result<()>; + ) -> Result<(), DownloaderError>; async fn add_torrent_tags( &self, hashes: Vec, tags: Vec, - ) -> color_eyre::eyre::Result<()>; + ) -> Result<(), DownloaderError>; - async fn add_category(&self, category: &str) -> color_eyre::eyre::Result<()>; + async fn add_category(&self, category: &str) -> Result<(), DownloaderError>; fn get_save_path(&self, sub_path: &Path) -> PathBuf; } diff --git a/apps/recorder/src/downloader/errors.rs b/apps/recorder/src/downloader/errors.rs new file mode 100644 index 0000000..7c21337 --- /dev/null +++ b/apps/recorder/src/downloader/errors.rs @@ -0,0 +1,58 @@ +use std::{borrow::Cow, time::Duration}; + +use snafu::prelude::*; + +use crate::errors::OptionWhateverAsync; + +#[derive(Snafu, Debug)] +#[snafu(visibility(pub(crate)))] +pub enum DownloaderError { + #[snafu(display("Invalid mime (expected {expected:?}, got {found:?})"))] + DownloadMimeError { expected: String, found: String }, + #[snafu(display("Invalid url schema (expected {expected:?}, got {found:?})"))] + DownloadSchemaError { expected: String, found: String }, + #[snafu(transparent)] + DownloadUrlParseError { source: url::ParseError }, + #[snafu(display("Invalid url format: {reason}"))] + DownloadUrlFormatError { reason: Cow<'static, str> }, + #[snafu(transparent)] + QBitAPIError { source: qbit_rs::Error }, + #[snafu(display("Timeout error (action = {action}, timeout = {timeout:?})"))] + DownloadTimeoutError { + action: Cow<'static, str>, + timeout: Duration, + }, + #[snafu(display("Invalid torrent file format"))] + TorrentFileFormatError, + #[snafu(display("Invalid magnet format (url = {url})"))] + MagnetFormatError { url: String }, + #[snafu(display("Failed to fetch: {source}"))] + DownloadFetchError { + #[snafu(source)] + source: Box, + }, + #[snafu(display("{message}"))] + Whatever { + message: String, + #[snafu(source(from(Box, OptionWhateverAsync::some)))] + source: OptionWhateverAsync, + }, +} + +impl snafu::FromString for DownloaderError { + type Source = Box; + + fn without_source(message: String) -> Self { + Self::Whatever { + message, + source: OptionWhateverAsync::none(), + } + } + + fn with_source(source: Self::Source, message: String) -> Self { + Self::Whatever { + message, + source: OptionWhateverAsync::some(source), + } + } +} diff --git a/apps/recorder/src/download/mod.rs b/apps/recorder/src/downloader/mod.rs similarity index 59% rename from apps/recorder/src/download/mod.rs rename to apps/recorder/src/downloader/mod.rs index 32a143e..7409a19 100644 --- a/apps/recorder/src/download/mod.rs +++ b/apps/recorder/src/downloader/mod.rs @@ -1,15 +1,15 @@ pub mod core; -pub mod error; +pub mod errors; pub mod qbit; pub mod rqbit; pub mod utils; pub use core::{ - BITTORRENT_MIME_TYPE, MAGNET_SCHEMA, Torrent, TorrentContent, TorrentDownloader, TorrentFilter, - TorrentSource, + Torrent, TorrentContent, TorrentDownloader, TorrentFilter, TorrentSource, BITTORRENT_MIME_TYPE, + MAGNET_SCHEMA, }; -pub use error::TorrentDownloadError; +pub use errors::DownloaderError; pub use qbit::{ QBittorrentDownloader, QBittorrentDownloaderCreation, QbitTorrent, QbitTorrentContent, QbitTorrentFile, QbitTorrentFilter, QbitTorrentSource, diff --git a/apps/recorder/src/download/qbit/mod.rs b/apps/recorder/src/downloader/qbit/mod.rs similarity index 90% rename from apps/recorder/src/download/qbit/mod.rs rename to apps/recorder/src/downloader/qbit/mod.rs index 7582caa..177fa28 100644 --- a/apps/recorder/src/download/qbit/mod.rs +++ b/apps/recorder/src/downloader/qbit/mod.rs @@ -3,7 +3,6 @@ use std::{ }; use async_trait::async_trait; -use color_eyre::eyre::OptionExt; use futures::future::try_join_all; pub use qbit_rs::model::{ Torrent as QbitTorrent, TorrentContent as QbitTorrentContent, TorrentFile as QbitTorrentFile, @@ -14,12 +13,13 @@ use qbit_rs::{ model::{AddTorrentArg, Credential, GetTorrentListArg, NonEmptyStr, SyncData}, }; use quirks_path::{Path, PathBuf}; +use snafu::prelude::*; use tokio::time::sleep; use tracing::instrument; use url::Url; use super::{ - Torrent, TorrentDownloadError, TorrentDownloader, TorrentFilter, TorrentSource, + DownloaderError, Torrent, TorrentDownloader, TorrentFilter, TorrentSource, utils::path_equals_as_file_url, }; @@ -83,18 +83,14 @@ pub struct QBittorrentDownloader { impl QBittorrentDownloader { pub async fn from_creation( creation: QBittorrentDownloaderCreation, - ) -> Result { - let endpoint_url = - Url::parse(&creation.endpoint).map_err(TorrentDownloadError::InvalidUrlParse)?; + ) -> Result { + let endpoint_url = Url::parse(&creation.endpoint)?; let credential = Credential::new(creation.username, creation.password); let client = Qbit::new(endpoint_url.clone(), credential); - client - .login(false) - .await - .map_err(TorrentDownloadError::QBitAPIError)?; + client.login(false).await?; client.sync(None).await?; @@ -108,7 +104,7 @@ impl QBittorrentDownloader { } #[instrument(level = "debug")] - pub async fn api_version(&self) -> color_eyre::eyre::Result { + pub async fn api_version(&self) -> Result { let result = self.client.get_webapi_version().await?; Ok(result) } @@ -119,11 +115,11 @@ impl QBittorrentDownloader { fetch_data_fn: G, mut stop_wait_fn: F, timeout: Option, - ) -> color_eyre::eyre::Result<()> + ) -> Result<(), DownloaderError> where H: FnOnce() -> E, G: Fn(Arc, E) -> Fut, - Fut: Future>, + Fut: Future>, F: FnMut(&D) -> bool, E: Clone, D: Debug + serde::Serialize, @@ -142,11 +138,10 @@ impl QBittorrentDownloader { break; } else { tracing::warn!(name = "wait_until timeout", sync_data = serde_json::to_string(&sync_data).unwrap(), timeout = ?timeout); - return Err(TorrentDownloadError::TimeoutError { + return Err(DownloaderError::DownloadTimeoutError { action: Cow::Borrowed("QBittorrentDownloader::wait_unit"), timeout, - } - .into()); + }); } } let sync_data = fetch_data_fn(self.client.clone(), env.clone()).await?; @@ -164,7 +159,7 @@ impl QBittorrentDownloader { arg: GetTorrentListArg, stop_wait_fn: F, timeout: Option, - ) -> color_eyre::eyre::Result<()> + ) -> Result<(), DownloaderError> where F: FnMut(&Vec) -> bool, { @@ -172,7 +167,7 @@ impl QBittorrentDownloader { || arg, async move |client: Arc, arg: GetTorrentListArg| - -> color_eyre::eyre::Result> { + -> Result, DownloaderError> { let data = client.get_torrent_list(arg).await?; Ok(data) }, @@ -187,10 +182,10 @@ impl QBittorrentDownloader { &self, stop_wait_fn: F, timeout: Option, - ) -> color_eyre::eyre::Result<()> { + ) -> Result<(), DownloaderError> { self.wait_until( || (), - async move |client: Arc, _| -> color_eyre::eyre::Result { + async move |client: Arc, _| -> Result { let data = client.sync(None).await?; Ok(data) }, @@ -206,12 +201,12 @@ impl QBittorrentDownloader { hash: &str, stop_wait_fn: F, timeout: Option, - ) -> color_eyre::eyre::Result<()> { + ) -> Result<(), DownloaderError> { self.wait_until( || Arc::new(hash.to_string()), async move |client: Arc, hash_arc: Arc| - -> color_eyre::eyre::Result> { + -> Result, DownloaderError> { let data = client.get_torrent_contents(hash_arc.as_str(), None).await?; Ok(data) }, @@ -230,7 +225,7 @@ impl TorrentDownloader for QBittorrentDownloader { status_filter: TorrentFilter, category: Option, tag: Option, - ) -> color_eyre::eyre::Result> { + ) -> Result, DownloaderError> { let arg = GetTorrentListArg { filter: Some(status_filter.into()), category, @@ -259,7 +254,7 @@ impl TorrentDownloader for QBittorrentDownloader { source: TorrentSource, save_path: String, category: Option<&str>, - ) -> color_eyre::eyre::Result<()> { + ) -> Result<(), DownloaderError> { let arg = AddTorrentArg { source: source.clone().into(), savepath: Some(save_path), @@ -293,7 +288,7 @@ impl TorrentDownloader for QBittorrentDownloader { } #[instrument(level = "debug", skip(self))] - async fn delete_torrents(&self, hashes: Vec) -> color_eyre::eyre::Result<()> { + async fn delete_torrents(&self, hashes: Vec) -> Result<(), DownloaderError> { self.client .delete_torrents(hashes.clone(), Some(true)) .await?; @@ -314,7 +309,7 @@ impl TorrentDownloader for QBittorrentDownloader { hash: &str, old_path: &str, new_path: &str, - ) -> color_eyre::eyre::Result<()> { + ) -> Result<(), DownloaderError> { self.client.rename_file(hash, old_path, new_path).await?; let new_path = self.save_path.join(new_path); let save_path = self.save_path.as_path(); @@ -340,7 +335,7 @@ impl TorrentDownloader for QBittorrentDownloader { &self, hashes: Vec, new_path: &str, - ) -> color_eyre::eyre::Result<()> { + ) -> Result<(), DownloaderError> { self.client .set_torrent_location(hashes.clone(), new_path) .await?; @@ -364,7 +359,7 @@ impl TorrentDownloader for QBittorrentDownloader { Ok(()) } - async fn get_torrent_path(&self, hashes: String) -> color_eyre::eyre::Result> { + async fn get_torrent_path(&self, hashes: String) -> Result, DownloaderError> { let mut torrent_list = self .client .get_torrent_list(GetTorrentListArg { @@ -372,12 +367,14 @@ impl TorrentDownloader for QBittorrentDownloader { ..Default::default() }) .await?; - let torrent = torrent_list.first_mut().ok_or_eyre("No torrent found")?; + let torrent = torrent_list + .first_mut() + .whatever_context::<_, DownloaderError>("No torrent found")?; Ok(torrent.save_path.take()) } #[instrument(level = "debug", skip(self))] - async fn check_connection(&self) -> color_eyre::eyre::Result<()> { + async fn check_connection(&self) -> Result<(), DownloaderError> { self.api_version().await?; Ok(()) } @@ -387,7 +384,7 @@ impl TorrentDownloader for QBittorrentDownloader { &self, hashes: Vec, category: &str, - ) -> color_eyre::eyre::Result<()> { + ) -> Result<(), DownloaderError> { let result = self .client .set_torrent_category(hashes.clone(), category) @@ -420,9 +417,9 @@ impl TorrentDownloader for QBittorrentDownloader { &self, hashes: Vec, tags: Vec, - ) -> color_eyre::eyre::Result<()> { + ) -> Result<(), DownloaderError> { if tags.is_empty() { - return Err(color_eyre::eyre::eyre!("add torrent tags can not be empty")); + whatever!("add torrent tags can not be empty"); } self.client .add_torrent_tags(hashes.clone(), tags.clone()) @@ -450,10 +447,11 @@ impl TorrentDownloader for QBittorrentDownloader { } #[instrument(level = "debug", skip(self))] - async fn add_category(&self, category: &str) -> color_eyre::eyre::Result<()> { + async fn add_category(&self, category: &str) -> Result<(), DownloaderError> { self.client .add_category( - NonEmptyStr::new(category).ok_or_eyre("category can not be empty")?, + NonEmptyStr::new(category) + .whatever_context::<_, DownloaderError>("category can not be empty")?, self.save_path.as_str(), ) .await?; @@ -490,7 +488,7 @@ pub mod tests { use itertools::Itertools; use super::*; - use crate::test_utils::fetch::build_testing_http_client; + use crate::{errors::RResult, test_utils::fetch::build_testing_http_client}; fn get_tmp_qbit_test_folder() -> &'static str { if cfg!(all(windows, not(feature = "testcontainers"))) { @@ -502,8 +500,7 @@ pub mod tests { #[cfg(feature = "testcontainers")] pub async fn create_qbit_testcontainer() - -> color_eyre::eyre::Result> - { + -> RResult> { use testcontainers::{ GenericImage, core::{ @@ -539,7 +536,7 @@ pub mod tests { #[cfg(feature = "testcontainers")] #[tokio::test(flavor = "multi_thread")] - async fn test_qbittorrent_downloader() -> color_eyre::eyre::Result<()> { + async fn test_qbittorrent_downloader() -> RResult<()> { use testcontainers::runners::AsyncRunner; use tokio::io::AsyncReadExt; @@ -590,7 +587,7 @@ pub mod tests { async fn test_qbittorrent_downloader_impl( username: Option<&str>, password: Option<&str>, - ) -> color_eyre::eyre::Result<()> { + ) -> RResult<()> { let http_client = build_testing_http_client()?; let base_save_path = Path::new(get_tmp_qbit_test_folder()); @@ -625,7 +622,7 @@ pub mod tests { .add_torrents(torrent_source, save_path.to_string(), Some("bangumi")) .await?; - let get_torrent = async || -> color_eyre::eyre::Result { + let get_torrent = async || -> Result { let torrent_infos = downloader .get_torrents_info(TorrentFilter::All, None, None) .await?; @@ -633,7 +630,7 @@ pub mod tests { let result = torrent_infos .into_iter() .find(|t| (t.get_hash() == Some("47ee2d69e7f19af783ad896541a07b012676f858"))) - .ok_or_eyre("no torrent")?; + .whatever_context::<_, DownloaderError>("no torrent")?; Ok(result) }; diff --git a/apps/recorder/src/downloader/rqbit/mod.rs b/apps/recorder/src/downloader/rqbit/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/recorder/src/downloader/rqbit/mod.rs @@ -0,0 +1 @@ + diff --git a/apps/recorder/src/download/utils.rs b/apps/recorder/src/downloader/utils.rs similarity index 100% rename from apps/recorder/src/download/utils.rs rename to apps/recorder/src/downloader/utils.rs diff --git a/apps/recorder/src/errors/mod.rs b/apps/recorder/src/errors/mod.rs index dfd0cf3..0f0a741 100644 --- a/apps/recorder/src/errors/mod.rs +++ b/apps/recorder/src/errors/mod.rs @@ -1,4 +1,5 @@ -use std::{borrow::Cow, error::Error as StdError}; +pub mod whatever; +use std::borrow::Cow; use axum::{ Json, @@ -6,105 +7,157 @@ use axum::{ }; use http::StatusCode; use serde::{Deserialize, Deserializer, Serialize}; -use thiserror::Error as ThisError; +use snafu::prelude::*; +pub use whatever::OptionWhateverAsync; -use crate::{auth::AuthError, fetch::HttpClientError}; +use crate::{auth::AuthError, downloader::DownloaderError, fetch::HttpClientError}; -#[derive(ThisError, Debug)] +#[derive(Snafu, Debug)] +#[snafu(visibility(pub(crate)))] pub enum RError { - #[error(transparent)] - InvalidMethodError(#[from] http::method::InvalidMethod), - #[error(transparent)] - InvalidHeaderNameError(#[from] http::header::InvalidHeaderName), - #[error(transparent)] - TracingAppenderInitError(#[from] tracing_appender::rolling::InitError), - #[error(transparent)] - GraphQLSchemaError(#[from] async_graphql::dynamic::SchemaError), - #[error(transparent)] - AuthError(#[from] AuthError), - #[error(transparent)] - RSSError(#[from] rss::Error), - #[error(transparent)] - DotEnvError(#[from] dotenv::Error), - #[error(transparent)] - TeraError(#[from] tera::Error), - #[error(transparent)] - IOError(#[from] std::io::Error), - #[error(transparent)] - DbError(#[from] sea_orm::DbErr), - #[error(transparent)] - CookieParseError(#[from] cookie::ParseError), - #[error(transparent)] - FigmentError(#[from] figment::Error), - #[error(transparent)] - SerdeJsonError(#[from] serde_json::Error), - #[error(transparent)] - ReqwestMiddlewareError(#[from] reqwest_middleware::Error), - #[error(transparent)] - ReqwestError(#[from] reqwest::Error), - #[error(transparent)] - ParseUrlError(#[from] url::ParseError), - #[error(transparent)] - OpenDALError(#[from] opendal::Error), - #[error(transparent)] - InvalidHeaderValueError(#[from] http::header::InvalidHeaderValue), - #[error(transparent)] - HttpClientError(#[from] HttpClientError), - #[error("Extract {desc} with mime error, expected {expected}, but got {found}")] + #[snafu(transparent, context(false))] + FancyRegexError { + #[snafu(source(from(fancy_regex::Error, Box::new)))] + source: Box, + }, + #[snafu(transparent)] + RegexError { source: regex::Error }, + #[snafu(transparent)] + InvalidMethodError { source: http::method::InvalidMethod }, + #[snafu(transparent)] + InvalidHeaderNameError { + source: http::header::InvalidHeaderName, + }, + #[snafu(transparent)] + TracingAppenderInitError { + source: tracing_appender::rolling::InitError, + }, + #[snafu(transparent)] + GraphQLSchemaError { + source: async_graphql::dynamic::SchemaError, + }, + #[snafu(transparent)] + AuthError { source: AuthError }, + #[snafu(transparent)] + DownloadError { source: DownloaderError }, + #[snafu(transparent)] + RSSError { source: rss::Error }, + #[snafu(transparent)] + DotEnvError { source: dotenv::Error }, + #[snafu(transparent)] + TeraError { source: tera::Error }, + #[snafu(transparent)] + IOError { source: std::io::Error }, + #[snafu(transparent)] + DbError { source: sea_orm::DbErr }, + #[snafu(transparent)] + CookieParseError { source: cookie::ParseError }, + #[snafu(transparent, context(false))] + FigmentError { + #[snafu(source(from(figment::Error, Box::new)))] + source: Box, + }, + #[snafu(transparent)] + SerdeJsonError { source: serde_json::Error }, + #[snafu(transparent)] + ReqwestMiddlewareError { source: reqwest_middleware::Error }, + #[snafu(transparent)] + ReqwestError { source: reqwest::Error }, + #[snafu(transparent)] + ParseUrlError { source: url::ParseError }, + #[snafu(display("{source}"), context(false))] + OpenDALError { + #[snafu(source(from(opendal::Error, Box::new)))] + source: Box, + }, + #[snafu(transparent)] + InvalidHeaderValueError { + source: http::header::InvalidHeaderValue, + }, + #[snafu(transparent)] + HttpClientError { source: HttpClientError }, + #[cfg(all(feature = "testcontainers", test))] + #[snafu(transparent)] + TestcontainersError { + source: testcontainers::TestcontainersError, + }, + #[snafu(display("Extract {desc} with mime error, expected {expected}, but got {found}"))] MimeError { desc: String, expected: String, found: String, }, - #[error("Invalid or unknown format in extracting mikan rss")] + #[snafu(display("Invalid or unknown format in extracting mikan rss"))] MikanRssInvalidFormatError, - #[error("Invalid field {field} in extracting mikan rss")] + #[snafu(display("Invalid field {field} in extracting mikan rss"))] MikanRssInvalidFieldError { field: Cow<'static, str>, - #[source] - source: Option>, + #[snafu(source(from(Box, OptionWhateverAsync::some)))] + source: OptionWhateverAsync, }, - #[error("Missing field {field} in extracting mikan meta")] + #[snafu(display("Missing field {field} in extracting mikan meta"))] MikanMetaMissingFieldError { field: Cow<'static, str>, - #[source] - source: Option>, + #[snafu(source(from(Box, OptionWhateverAsync::some)))] + source: OptionWhateverAsync, }, - #[error("Model Entity {entity} not found")] + #[snafu(display("Model Entity {entity} not found"))] ModelEntityNotFound { entity: Cow<'static, str> }, - #[error("{0}")] - CustomMessageStr(&'static str), - #[error("{0}")] - CustomMessageString(String), + #[snafu(display("{message}"))] + Whatever { + message: String, + #[snafu(source(from(Box, OptionWhateverAsync::some)))] + source: OptionWhateverAsync, + }, } impl RError { pub fn from_mikan_meta_missing_field(field: Cow<'static, str>) -> Self { Self::MikanMetaMissingFieldError { field, - source: None, + source: None.into(), } } pub fn from_mikan_rss_invalid_field(field: Cow<'static, str>) -> Self { Self::MikanRssInvalidFieldError { field, - source: None, + source: None.into(), } } pub fn from_mikan_rss_invalid_field_and_source( field: Cow<'static, str>, - source: Box, + source: impl std::error::Error + Send + Sync + 'static, ) -> Self { Self::MikanRssInvalidFieldError { field, - source: Some(source), + source: OptionWhateverAsync::some_boxed(source), } } pub fn from_db_record_not_found(detail: T) -> Self { - Self::DbError(sea_orm::DbErr::RecordNotFound(detail.to_string())) + Self::DbError { + source: sea_orm::DbErr::RecordNotFound(detail.to_string()), + } + } +} + +impl snafu::FromString for RError { + type Source = Box; + + fn without_source(message: String) -> Self { + Self::Whatever { + message, + source: OptionWhateverAsync::none(), + } + } + + fn with_source(source: Self::Source, message: String) -> Self { + Self::Whatever { + message, + source: OptionWhateverAsync::some(source), + } } } @@ -129,7 +182,7 @@ impl From for StandardErrorResponse { impl IntoResponse for RError { fn into_response(self) -> Response { match self { - Self::AuthError(auth_error) => auth_error.into_response(), + Self::AuthError { source: auth_error } => auth_error.into_response(), err => ( StatusCode::INTERNAL_SERVER_ERROR, Json::(StandardErrorResponse::from(err.to_string())), @@ -154,7 +207,10 @@ impl<'de> Deserialize<'de> for RError { D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; - Ok(Self::CustomMessageString(s)) + Ok(Self::Whatever { + message: s, + source: None.into(), + }) } } diff --git a/apps/recorder/src/errors/whatever.rs b/apps/recorder/src/errors/whatever.rs new file mode 100644 index 0000000..2736a63 --- /dev/null +++ b/apps/recorder/src/errors/whatever.rs @@ -0,0 +1,55 @@ +use std::fmt::Display; + +#[derive(Debug)] +pub struct OptionWhateverAsync(Option>); + +impl AsRef for OptionWhateverAsync { + fn as_ref(&self) -> &(dyn snafu::Error + 'static) { + self + } +} + +impl OptionWhateverAsync { + pub fn some_boxed(e: E) -> Self { + Self(Some(Box::new(e))) + } + + pub fn some(e: Box) -> Self { + Self(Some(e)) + } + + pub fn none() -> Self { + Self(None) + } +} + +impl Display for OptionWhateverAsync { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.0 { + Some(e) => e.fmt(f), + None => write!(f, "None"), + } + } +} + +impl snafu::Error for OptionWhateverAsync { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + None + } + + fn cause(&self) -> Option<&dyn std::error::Error> { + self.source() + } +} + +impl From>> for OptionWhateverAsync { + fn from(value: Option>) -> Self { + Self(value) + } +} + +impl From> for OptionWhateverAsync { + fn from(value: Box) -> Self { + Self::some(value) + } +} diff --git a/apps/recorder/src/extract/mikan/rss_extract.rs b/apps/recorder/src/extract/mikan/rss_extract.rs index b39b96e..bc0de72 100644 --- a/apps/recorder/src/extract/mikan/rss_extract.rs +++ b/apps/recorder/src/extract/mikan/rss_extract.rs @@ -8,7 +8,7 @@ use tracing::instrument; use url::Url; use crate::{ - download::core::BITTORRENT_MIME_TYPE, + downloader::core::BITTORRENT_MIME_TYPE, errors::{RError, RResult}, extract::mikan::{ MikanClient, @@ -120,10 +120,10 @@ impl TryFrom for MikanRssItem { .title .ok_or_else(|| RError::from_mikan_rss_invalid_field(Cow::Borrowed("title:title")))?; - let enclosure_url = Url::parse(&enclosure.url).map_err(|inner| { + let enclosure_url = Url::parse(&enclosure.url).map_err(|err| { RError::from_mikan_rss_invalid_field_and_source( - Cow::Borrowed("enclosure_url:enclosure.link"), - Box::new(inner), + "enclosure_url:enclosure.link".into(), + err, ) })?; @@ -334,12 +334,12 @@ pub async fn extract_mikan_rss_channel_from_rss_link( mod tests { use std::assert_matches::assert_matches; - use color_eyre::eyre; use rstest::rstest; use url::Url; use crate::{ - download::core::BITTORRENT_MIME_TYPE, + downloader::core::BITTORRENT_MIME_TYPE, + errors::RResult, extract::mikan::{ MikanBangumiAggregationRssChannel, MikanBangumiRssChannel, MikanRssChannel, extract_mikan_rss_channel_from_rss_link, @@ -349,7 +349,7 @@ mod tests { #[rstest] #[tokio::test] - async fn test_parse_mikan_rss_channel_from_rss_link() -> eyre::Result<()> { + async fn test_parse_mikan_rss_channel_from_rss_link() -> RResult<()> { let mut mikan_server = mockito::Server::new_async().await; let mikan_base_url = Url::parse(&mikan_server.url())?; diff --git a/apps/recorder/src/extract/mikan/web_extract.rs b/apps/recorder/src/extract/mikan/web_extract.rs index 2952df5..62b463a 100644 --- a/apps/recorder/src/extract/mikan/web_extract.rs +++ b/apps/recorder/src/extract/mikan/web_extract.rs @@ -491,7 +491,6 @@ pub fn extract_mikan_bangumis_meta_from_my_bangumi_page( #[cfg(test)] mod test { #![allow(unused_variables)] - use color_eyre::eyre; use futures::{TryStreamExt, pin_mut}; use http::header; use rstest::{fixture, rstest}; @@ -512,7 +511,7 @@ mod test { #[rstest] #[tokio::test] - async fn test_extract_mikan_poster_from_src(before_each: ()) -> eyre::Result<()> { + async fn test_extract_mikan_poster_from_src(before_each: ()) -> RResult<()> { let mut mikan_server = mockito::Server::new_async().await; let mikan_base_url = Url::parse(&mikan_server.url())?; let mikan_client = build_testing_mikan_client(mikan_base_url.clone()).await?; @@ -543,7 +542,7 @@ mod test { #[rstest] #[tokio::test] - async fn test_extract_mikan_episode(before_each: ()) -> eyre::Result<()> { + async fn test_extract_mikan_episode(before_each: ()) -> RResult<()> { let mut mikan_server = mockito::Server::new_async().await; let mikan_base_url = Url::parse(&mikan_server.url())?; let mikan_client = build_testing_mikan_client(mikan_base_url.clone()).await?; @@ -583,9 +582,7 @@ mod test { #[rstest] #[tokio::test] - async fn test_extract_mikan_bangumi_meta_from_bangumi_homepage( - before_each: (), - ) -> eyre::Result<()> { + async fn test_extract_mikan_bangumi_meta_from_bangumi_homepage(before_each: ()) -> RResult<()> { let mut mikan_server = mockito::Server::new_async().await; let mikan_base_url = Url::parse(&mikan_server.url())?; let mikan_client = build_testing_mikan_client(mikan_base_url.clone()).await?; @@ -622,9 +619,7 @@ mod test { #[rstest] #[tokio::test] - async fn test_extract_mikan_bangumis_meta_from_my_bangumi_page( - before_each: (), - ) -> eyre::Result<()> { + async fn test_extract_mikan_bangumis_meta_from_my_bangumi_page(before_each: ()) -> RResult<()> { let mut mikan_server = mockito::Server::new_async().await; let mikan_base_url = Url::parse(&mikan_server.url())?; diff --git a/apps/recorder/src/extract/rawname/parser.rs b/apps/recorder/src/extract/rawname/parser.rs index 52aef4e..38475af 100644 --- a/apps/recorder/src/extract/rawname/parser.rs +++ b/apps/recorder/src/extract/rawname/parser.rs @@ -7,8 +7,12 @@ use itertools::Itertools; use lazy_static::lazy_static; use regex::Regex; use serde::{Deserialize, Serialize}; +use snafu::whatever; -use crate::extract::defs::{DIGIT_1PLUS_REG, ZH_NUM_MAP, ZH_NUM_RE}; +use crate::{ + errors::RResult, + extract::defs::{DIGIT_1PLUS_REG, ZH_NUM_MAP, ZH_NUM_RE}, +}; const NAME_EXTRACT_REPLACE_ADHOC1_REPLACED: &str = "$1/$2"; @@ -71,10 +75,7 @@ fn replace_ch_bracket_to_en(raw_name: &str) -> String { raw_name.replace('【', "[").replace('】', "]") } -fn title_body_pre_process( - title_body: &str, - fansub: Option<&str>, -) -> color_eyre::eyre::Result { +fn title_body_pre_process(title_body: &str, fansub: Option<&str>) -> RResult { let raw_without_fansub = if let Some(fansub) = fansub { let fan_sub_re = Regex::new(&format!(".{fansub}."))?; fan_sub_re.replace_all(title_body, "") @@ -262,7 +263,7 @@ pub fn check_is_movie(title: &str) -> bool { MOVIE_TITLE_RE.is_match(title) } -pub fn parse_episode_meta_from_raw_name(s: &str) -> color_eyre::eyre::Result { +pub fn parse_episode_meta_from_raw_name(s: &str) -> RResult { let raw_title = s.trim(); let raw_title_without_ch_brackets = replace_ch_bracket_to_en(raw_title); let fansub = extract_fansub(&raw_title_without_ch_brackets); @@ -315,10 +316,7 @@ pub fn parse_episode_meta_from_raw_name(s: &str) -> color_eyre::eyre::Result = { @@ -101,10 +104,12 @@ pub fn parse_episode_media_meta_from_torrent( torrent_path: &Path, torrent_name: Option<&str>, season: Option, -) -> color_eyre::eyre::Result { +) -> RResult { let media_name = torrent_path .file_name() - .ok_or_else(|| color_eyre::eyre::eyre!("failed to get file name of {}", torrent_path))?; + .with_whatever_context::<_, _, RError>(|| { + format!("failed to get file name of {}", torrent_path) + })?; let mut match_obj = None; for rule in TORRENT_EP_PARSE_RULES.iter() { match_obj = if let Some(torrent_name) = torrent_name.as_ref() { @@ -119,7 +124,7 @@ pub fn parse_episode_media_meta_from_torrent( if let Some(match_obj) = match_obj { let group_season_and_title = match_obj .get(1) - .ok_or_else(|| color_eyre::eyre::eyre!("should have 1 group"))? + .whatever_context::<_, RError>("should have 1 group")? .as_str(); let (fansub, season_and_title) = get_fansub(group_season_and_title); let (title, season) = if let Some(season) = season { @@ -130,7 +135,7 @@ pub fn parse_episode_media_meta_from_torrent( }; let episode_index = match_obj .get(2) - .ok_or_eyre("should have 2 group")? + .whatever_context::<_, RError>("should have 2 group")? .as_str() .parse::() .unwrap_or(1); @@ -146,11 +151,11 @@ pub fn parse_episode_media_meta_from_torrent( extname, }) } else { - Err(color_eyre::eyre::eyre!( + whatever!( "failed to parse episode media meta from torrent_path='{}' torrent_name='{:?}'", torrent_path, torrent_name - )) + ) } } @@ -158,11 +163,13 @@ pub fn parse_episode_subtitle_meta_from_torrent( torrent_path: &Path, torrent_name: Option<&str>, season: Option, -) -> color_eyre::eyre::Result { +) -> RResult { let media_meta = parse_episode_media_meta_from_torrent(torrent_path, torrent_name, season)?; let media_name = torrent_path .file_name() - .ok_or_else(|| color_eyre::eyre::eyre!("failed to get file name of {}", torrent_path))?; + .with_whatever_context::<_, _, RError>(|| { + format!("failed to get file name of {}", torrent_path) + })?; let lang = get_subtitle_lang(media_name); @@ -177,8 +184,8 @@ mod tests { use quirks_path::Path; use super::{ - parse_episode_media_meta_from_torrent, parse_episode_subtitle_meta_from_torrent, - TorrentEpisodeMediaMeta, TorrentEpisodeSubtitleMeta, + TorrentEpisodeMediaMeta, TorrentEpisodeSubtitleMeta, parse_episode_media_meta_from_torrent, + parse_episode_subtitle_meta_from_torrent, }; #[test] diff --git a/apps/recorder/src/fetch/client/core.rs b/apps/recorder/src/fetch/client/core.rs index 5089a2b..1d00f6c 100644 --- a/apps/recorder/src/fetch/client/core.rs +++ b/apps/recorder/src/fetch/client/core.rs @@ -14,7 +14,7 @@ use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff}; use reqwest_tracing::TracingMiddleware; use serde::{Deserialize, Serialize}; use serde_with::serde_as; -use thiserror::Error; +use snafu::Snafu; use super::HttpClientSecrecyDataTrait; use crate::fetch::get_random_mobile_ua; @@ -101,14 +101,14 @@ impl CacheManager for CacheBackend { } } -#[derive(Debug, Error)] +#[derive(Debug, Snafu)] pub enum HttpClientError { - #[error(transparent)] - ReqwestError(#[from] reqwest::Error), - #[error(transparent)] - ReqwestMiddlewareError(#[from] reqwest_middleware::Error), - #[error(transparent)] - HttpError(#[from] http::Error), + #[snafu(transparent)] + ReqwestError { source: reqwest::Error }, + #[snafu(transparent)] + ReqwestMiddlewareError { source: reqwest_middleware::Error }, + #[snafu(transparent)] + HttpError { source: http::Error }, } pub trait HttpClientTrait: Deref + Debug {} diff --git a/apps/recorder/src/fetch/oidc.rs b/apps/recorder/src/fetch/oidc.rs index 5159b41..f7f68bd 100644 --- a/apps/recorder/src/fetch/oidc.rs +++ b/apps/recorder/src/fetch/oidc.rs @@ -2,7 +2,7 @@ use std::{future::Future, pin::Pin}; use axum::http; -use super::{client::HttpClientError, HttpClient}; +use super::{HttpClient, client::HttpClientError}; impl<'c> openidconnect::AsyncHttpClient<'c> for HttpClient { type Error = HttpClientError; @@ -30,7 +30,7 @@ impl<'c> openidconnect::AsyncHttpClient<'c> for HttpClient { builder .body(response.bytes().await?.to_vec()) - .map_err(HttpClientError::HttpError) + .map_err(HttpClientError::from) }) } } diff --git a/apps/recorder/src/lib.rs b/apps/recorder/src/lib.rs index 6885228..cf7bd39 100644 --- a/apps/recorder/src/lib.rs +++ b/apps/recorder/src/lib.rs @@ -5,14 +5,15 @@ impl_trait_in_bindings, iterator_try_collect, async_fn_traits, - let_chains + let_chains, + error_generic_member_access )] pub mod app; pub mod auth; pub mod cache; pub mod database; -pub mod download; +pub mod downloader; pub mod errors; pub mod extract; pub mod fetch; diff --git a/apps/recorder/src/logger/service.rs b/apps/recorder/src/logger/service.rs index 9a43335..50a6ea6 100644 --- a/apps/recorder/src/logger/service.rs +++ b/apps/recorder/src/logger/service.rs @@ -1,5 +1,6 @@ use std::sync::OnceLock; +use snafu::prelude::*; use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::{ EnvFilter, Layer, Registry, @@ -9,7 +10,7 @@ use tracing_subscriber::{ }; use super::{LogFormat, LogLevel, LogRotation, LoggerConfig}; -use crate::errors::{RError, RResult}; +use crate::errors::RResult; // Function to initialize the logger based on the provided configuration const MODULE_WHITELIST: &[&str] = &["sea_orm_migration", "tower_http", "sqlx::query", "sidekiq"]; @@ -119,9 +120,9 @@ impl LoggerService { let file_appender_layer = if file_appender_config.non_blocking { let (non_blocking_file_appender, work_guard) = tracing_appender::non_blocking(file_appender); - NONBLOCKING_WORK_GUARD_KEEP - .set(work_guard) - .map_err(|_| RError::CustomMessageStr("cannot lock for appender"))?; + if NONBLOCKING_WORK_GUARD_KEEP.set(work_guard).is_err() { + whatever!("cannot lock for appender"); + }; Self::init_layer( non_blocking_file_appender, &file_appender_config.format, diff --git a/apps/recorder/src/models/episodes.rs b/apps/recorder/src/models/episodes.rs index 89e830f..cb96ddc 100644 --- a/apps/recorder/src/models/episodes.rs +++ b/apps/recorder/src/models/episodes.rs @@ -191,7 +191,7 @@ impl ActiveModel { pub fn from_mikan_episode_meta( ctx: &dyn AppContextTrait, creation: MikanEpsiodeCreation, - ) -> color_eyre::eyre::Result { + ) -> RResult { let item = creation.episode; let bgm = creation.bangumi; let raw_meta = parse_episode_meta_from_raw_name(&item.episode_title) diff --git a/apps/recorder/src/storage/client.rs b/apps/recorder/src/storage/client.rs index d05b9d9..22a017f 100644 --- a/apps/recorder/src/storage/client.rs +++ b/apps/recorder/src/storage/client.rs @@ -142,7 +142,7 @@ impl StorageService { subscriber_pid: &str, bucket: Option<&str>, filename: &str, - ) -> color_eyre::eyre::Result { + ) -> RResult { match content_category { StorageContentCategory::Image => { let fullname = [ diff --git a/apps/recorder/src/test_utils/fetch.rs b/apps/recorder/src/test_utils/fetch.rs index 14413a3..0e57f3c 100644 --- a/apps/recorder/src/test_utils/fetch.rs +++ b/apps/recorder/src/test_utils/fetch.rs @@ -1,8 +1,6 @@ -use color_eyre::eyre; +use crate::{errors::RResult, fetch::HttpClient}; -use crate::fetch::HttpClient; - -pub fn build_testing_http_client() -> eyre::Result { +pub fn build_testing_http_client() -> RResult { let mikan_client = HttpClient::default(); Ok(mikan_client) } diff --git a/apps/recorder/src/test_utils/testcontainers.rs b/apps/recorder/src/test_utils/testcontainers.rs index fce85bb..2bc318f 100644 --- a/apps/recorder/src/test_utils/testcontainers.rs +++ b/apps/recorder/src/test_utils/testcontainers.rs @@ -2,7 +2,8 @@ use async_trait::async_trait; use bollard::container::ListContainersOptions; use itertools::Itertools; use testcontainers::{ - core::logs::consumer::logging_consumer::LoggingConsumer, ContainerRequest, Image, ImageExt, + ContainerRequest, Image, ImageExt, TestcontainersError, + core::logs::consumer::logging_consumer::LoggingConsumer, }; pub const TESTCONTAINERS_PROJECT_KEY: &str = "tech.enfw.testcontainers.project"; @@ -19,7 +20,7 @@ where container_label: &str, prune: bool, force: bool, - ) -> color_eyre::eyre::Result; + ) -> Result; fn with_default_log_consumer(self) -> Self; } @@ -34,7 +35,7 @@ where container_label: &str, prune: bool, force: bool, - ) -> color_eyre::eyre::Result { + ) -> Result { use std::collections::HashMap; use bollard::container::PruneContainersOptions; @@ -61,7 +62,8 @@ where filters: filters.clone(), ..Default::default() })) - .await?; + .await + .map_err(|err| TestcontainersError::Other(Box::new(err)))?; let remove_containers = result .iter() @@ -74,14 +76,16 @@ where .iter() .map(|c| client.stop_container(c, None)), ) - .await?; + .await + .map_err(|error| TestcontainersError::Other(Box::new(error)))?; tracing::warn!(name = "stop running containers", result = ?remove_containers); } let result = client .prune_containers(Some(PruneContainersOptions { filters })) - .await?; + .await + .map_err(|err| TestcontainersError::Other(Box::new(err)))?; tracing::warn!(name = "prune existed containers", result = ?result); } diff --git a/apps/recorder/src/web/controller/oidc/mod.rs b/apps/recorder/src/web/controller/oidc/mod.rs index dc79113..b8d4b35 100644 --- a/apps/recorder/src/web/controller/oidc/mod.rs +++ b/apps/recorder/src/web/controller/oidc/mod.rs @@ -6,12 +6,14 @@ use axum::{ http::request::Parts, routing::get, }; +use snafu::prelude::*; use super::core::Controller; use crate::{ app::AppContextTrait, auth::{ AuthError, AuthService, AuthServiceTrait, + errors::OidcRequestRedirectUriSnafu, oidc::{OidcAuthCallbackPayload, OidcAuthCallbackQuery, OidcAuthRequest}, }, errors::RResult, @@ -47,7 +49,8 @@ async fn oidc_auth( if let AuthService::Oidc(oidc_auth_service) = auth_service { let mut redirect_uri = ForwardedRelatedInfo::from_request_parts(&parts) .resolved_origin() - .ok_or_else(|| AuthError::OidcRequestRedirectUriError(url::ParseError::EmptyHost))?; + .ok_or(url::ParseError::EmptyHost) + .context(OidcRequestRedirectUriSnafu)?; redirect_uri.set_path(&format!("{CONTROLLER_PREFIX}/callback")); diff --git a/apps/recorder/src/web/middleware/remote_ip.rs b/apps/recorder/src/web/middleware/remote_ip.rs index 8f3527d..d7f474f 100644 --- a/apps/recorder/src/web/middleware/remote_ip.rs +++ b/apps/recorder/src/web/middleware/remote_ip.rs @@ -27,6 +27,7 @@ use axum::{ use futures_util::future::BoxFuture; use ipnetwork::IpNetwork; use serde::{Deserialize, Serialize}; +use snafu::ResultExt; use tower::{Layer, Service}; use tracing::error; @@ -233,12 +234,14 @@ impl RemoteIPLayer { proxies .iter() .map(|proxy| { - IpNetwork::from_str(proxy).map_err(|err| { - RError::CustomMessageString(format!( - "remote ip middleare cannot parse trusted proxy \ - configuration: `{proxy}`, reason: `{err}`", - )) - }) + IpNetwork::from_str(proxy) + .boxed() + .with_whatever_context::<_, _, RError>(|_| { + format!( + "remote ip middleare cannot parse trusted proxy \ + configuration: `{proxy}`" + ) + }) }) .collect::>>() }) @@ -284,8 +287,7 @@ where let xff_ip = maybe_get_forwarded(req.headers(), layer.trusted_proxies.as_ref()); let remote_ip = xff_ip.map_or_else( || { - let ip = req - .extensions() + req.extensions() .get::>() .map_or_else( || { @@ -296,8 +298,7 @@ where RemoteIP::None }, |info| RemoteIP::Socket(info.ip()), - ); - ip + ) }, RemoteIP::Forwarded, ); diff --git a/apps/recorder/src/web/middleware/secure_headers.rs b/apps/recorder/src/web/middleware/secure_headers.rs index 31b6b8e..8139c5f 100644 --- a/apps/recorder/src/web/middleware/secure_headers.rs +++ b/apps/recorder/src/web/middleware/secure_headers.rs @@ -18,13 +18,10 @@ use axum::{ use futures_util::future::BoxFuture; use serde::{Deserialize, Serialize}; use serde_json::{self, json}; +use snafu::whatever; use tower::{Layer, Service}; -use crate::{ - app::AppContextTrait, - web::middleware::MiddlewareLayer, - errors::{RError, RResult}, -}; +use crate::{app::AppContextTrait, errors::RResult, web::middleware::MiddlewareLayer}; static PRESETS: OnceLock>> = OnceLock::new(); fn get_presets() -> &'static HashMap> { @@ -115,7 +112,10 @@ impl MiddlewareLayer for SecureHeader { } /// Applies the secure headers layer to the application router - fn apply(&self, app: Router>) -> RResult>> { + fn apply( + &self, + app: Router>, + ) -> RResult>> { Ok(app.layer(SecureHeaders::new(self)?)) } } @@ -128,17 +128,15 @@ impl SecureHeader { let mut headers = vec![]; let preset = &self.preset; - let p = get_presets().get(preset).ok_or_else(|| { - RError::CustomMessageString(format!( - "secure_headers: a preset named `{preset}` does not exist" - )) - })?; - - Self::push_headers(&mut headers, p)?; - if let Some(overrides) = &self.overrides { - Self::push_headers(&mut headers, overrides)?; + if let Some(p) = get_presets().get(preset) { + Self::push_headers(&mut headers, p)?; + if let Some(overrides) = &self.overrides { + Self::push_headers(&mut headers, overrides)?; + } + Ok(headers) + } else { + whatever!("secure_headers: a preset named `{preset}` does not exist") } - Ok(headers) } /// Helper function to push headers into a mutable vector.