diff --git a/Cargo.lock b/Cargo.lock index 54f535599a..8ddf7a2d8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7229,6 +7229,28 @@ dependencies = [ "thiserror 1.0.63", ] +[[package]] +name = "muapi_client" +version = "0.0.1" +dependencies = [ + "chrono", + "cloudflare_errors", + "env", + "env_logger 0.11.9", + "errors", + "log", + "rand 0.9.2", + "serde", + "serde_derive", + "serde_json", + "testing", + "tokio", + "url", + "workspace-build-acceleration", + "wreq", + "wreq-util", +] + [[package]] name = "muda" version = "0.17.1" diff --git a/Cargo.toml b/Cargo.toml index 6663890c69..6829050baa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "crates/api_clients/midjourney_client", "crates/api_clients/openai_sora_client", "crates/api_clients/openart_client", + "crates/api_clients/muapi_client", "crates/api_clients/seedance2pro", "crates/api_clients/runwayml_client", "crates/api_clients/thumbnail_generator", @@ -240,6 +241,7 @@ memory_store = { path = "crates/lib/caching/memory_store" } midjourney_client = { path = "crates/api_clients/midjourney_client" } mimetypes = { path = "crates/lib/files/mimetypes" } openai_sora_client = { path = "crates/api_clients/openai_sora_client" } +muapi_client = { path = "crates/api_clients/muapi_client" } seedance2pro = { path = "crates/api_clients/seedance2pro" } primitives = { path = "crates/lib/data/primitives" } shared_service_components = { path = "crates/lib/shared_service_components" } diff --git a/crates/api_clients/muapi_client/Cargo.toml b/crates/api_clients/muapi_client/Cargo.toml new file mode 100644 index 0000000000..75ad0c5928 --- /dev/null +++ b/crates/api_clients/muapi_client/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "muapi_client" +edition = "2024" +version = "0.0.1" +publish = false + +[package.metadata.cargo-udeps.ignore] +normal = ["workspace-build-acceleration"] + +[lib] +name = "muapi_client" +path = "src/lib.rs" + +[dependencies] +workspace-build-acceleration.workspace = true +errors.workspace = true +chrono.workspace = true +cloudflare_errors.workspace = true +log.workspace = true +rand.workspace = true +serde.workspace = true +serde_derive.workspace = true +serde_json.workspace = true +wreq-util.workspace = true +wreq.workspace = true +url.workspace = true + +[dev-dependencies] +env.workspace = true +env_logger.workspace = true +testing = { path = "../../lib/testing" } +tokio = { version = "1", features = ["full"] } diff --git a/crates/api_clients/muapi_client/src/creds/mod.rs b/crates/api_clients/muapi_client/src/creds/mod.rs new file mode 100644 index 0000000000..9d0105bac8 --- /dev/null +++ b/crates/api_clients/muapi_client/src/creds/mod.rs @@ -0,0 +1,2 @@ +pub mod muapi_cookies; +pub mod muapi_session; diff --git a/crates/api_clients/muapi_client/src/creds/muapi_cookies.rs b/crates/api_clients/muapi_client/src/creds/muapi_cookies.rs new file mode 100644 index 0000000000..4ede4b2a99 --- /dev/null +++ b/crates/api_clients/muapi_client/src/creds/muapi_cookies.rs @@ -0,0 +1,24 @@ +/// Cookies are the credential required to interact with the Muapi API. + +#[derive(Clone)] +pub struct MuapiCookies { + cookies: String, +} + +impl MuapiCookies { + pub fn new(cookies: String) -> Self { + MuapiCookies { cookies } + } + + pub fn as_str(&self) -> &str { + &self.cookies + } + + pub fn as_bytes(&self) -> &[u8] { + self.cookies.as_bytes() + } + + pub fn to_string(&self) -> String { + self.cookies.clone() + } +} diff --git a/crates/api_clients/muapi_client/src/creds/muapi_session.rs b/crates/api_clients/muapi_client/src/creds/muapi_session.rs new file mode 100644 index 0000000000..689ed3603b --- /dev/null +++ b/crates/api_clients/muapi_client/src/creds/muapi_session.rs @@ -0,0 +1,19 @@ +use crate::creds::muapi_cookies::MuapiCookies; + +/// Holds the full session data needed to make authenticated requests to Muapi. +#[derive(Clone)] +pub struct MuapiSession { + pub cookies: MuapiCookies, +} + +impl MuapiSession { + pub fn new(cookies: MuapiCookies) -> Self { + MuapiSession { cookies } + } + + pub fn from_cookies_string(cookies: String) -> Self { + MuapiSession { + cookies: MuapiCookies::new(cookies), + } + } +} diff --git a/crates/api_clients/muapi_client/src/error/mod.rs b/crates/api_clients/muapi_client/src/error/mod.rs new file mode 100644 index 0000000000..80063abeaa --- /dev/null +++ b/crates/api_clients/muapi_client/src/error/mod.rs @@ -0,0 +1,4 @@ +pub mod muapi_client_error; +pub mod muapi_error; +pub mod muapi_generic_api_error; +pub mod muapi_specific_api_error; diff --git a/crates/api_clients/muapi_client/src/error/muapi_client_error.rs b/crates/api_clients/muapi_client/src/error/muapi_client_error.rs new file mode 100644 index 0000000000..e25dc24282 --- /dev/null +++ b/crates/api_clients/muapi_client/src/error/muapi_client_error.rs @@ -0,0 +1,26 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum MuapiClientError { + /// No cookies are present in the session. + NoCookiesPresent, + + /// Error parsing a URL. + UrlParseError(url::ParseError), + + /// An error was encountered in building the Wreq client. + WreqClientError(wreq::Error), +} + +impl Error for MuapiClientError {} + +impl Display for MuapiClientError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoCookiesPresent => write!(f, "No cookies present in the session."), + Self::UrlParseError(err) => write!(f, "URL parse error: {}", err), + Self::WreqClientError(err) => write!(f, "Wreq client error (during client creation): {}", err), + } + } +} diff --git a/crates/api_clients/muapi_client/src/error/muapi_error.rs b/crates/api_clients/muapi_client/src/error/muapi_error.rs new file mode 100644 index 0000000000..261fa7c2f0 --- /dev/null +++ b/crates/api_clients/muapi_client/src/error/muapi_error.rs @@ -0,0 +1,61 @@ +use crate::error::muapi_client_error::MuapiClientError; +use crate::error::muapi_generic_api_error::MuapiGenericApiError; +use crate::error::muapi_specific_api_error::MuapiSpecificApiError; +use cloudflare_errors::cloudflare_error::CloudflareError; +use std::error::Error; + +#[derive(Debug)] +pub enum MuapiError { + Client(MuapiClientError), + ApiSpecific(MuapiSpecificApiError), + ApiGeneric(MuapiGenericApiError), +} + +impl MuapiError { + pub fn is_having_downtime_issues(&self) -> bool { + match self { + Self::ApiGeneric(MuapiGenericApiError::CloudflareError(CloudflareError::BadGateway502)) => true, + Self::ApiGeneric(MuapiGenericApiError::CloudflareError(CloudflareError::GatewayTimeout504)) => true, + Self::ApiGeneric(MuapiGenericApiError::CloudflareError(CloudflareError::TimeoutOccurred524)) => true, + Self::ApiGeneric(MuapiGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body }) => { + match status_code.as_u16() { + 502 => true, + 504 => true, + 524 => true, + _ => false, + } + }, + _ => false, + } + } +} + +impl Error for MuapiError {} + +impl std::fmt::Display for MuapiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Client(e) => write!(f, "MuapiClientError: {:?}", e), + Self::ApiSpecific(e) => write!(f, "MuapiSpecificApiError: {:?}", e), + Self::ApiGeneric(e) => write!(f, "MuapiGenericApiError: {:?}", e), + } + } +} + +impl From for MuapiError { + fn from(error: MuapiClientError) -> Self { + Self::Client(error) + } +} + +impl From for MuapiError { + fn from(error: MuapiSpecificApiError) -> Self { + Self::ApiSpecific(error) + } +} + +impl From for MuapiError { + fn from(error: MuapiGenericApiError) -> Self { + Self::ApiGeneric(error) + } +} diff --git a/crates/api_clients/muapi_client/src/error/muapi_generic_api_error.rs b/crates/api_clients/muapi_client/src/error/muapi_generic_api_error.rs new file mode 100644 index 0000000000..6f2456f53c --- /dev/null +++ b/crates/api_clients/muapi_client/src/error/muapi_generic_api_error.rs @@ -0,0 +1,41 @@ +use cloudflare_errors::cloudflare_error::CloudflareError; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use wreq::StatusCode; + +#[derive(Debug)] +pub enum MuapiGenericApiError { + /// Specific Cloudflare errors. + CloudflareError(CloudflareError), + + /// serde_json::Error, likely from JSON deserialization schema mismatch. + /// Includes the original body. + SerdeResponseParseErrorWithBody(serde_json::Error, String), + + /// serde_json::Error on a non-200 (error) response. + /// Includes the original body. + SerdeParseErrorWithBodyOnNon200(serde_json::Error, String), + + /// An uncategorized bad HTTP response with status code and body. + UncategorizedBadResponseWithStatusAndBody { + status_code: StatusCode, + body: String, + }, + + /// An uncaught error from the HTTP client. + WreqError(wreq::Error), +} + +impl Error for MuapiGenericApiError {} + +impl Display for MuapiGenericApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::CloudflareError(err) => write!(f, "Cloudflare error: {}", err), + Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body), + Self::SerdeParseErrorWithBodyOnNon200(err, body) => write!(f, "Failed to parse non-200 response body: {:?}. Body: {}", err, body), + Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body), + Self::WreqError(err) => write!(f, "Wreq client error: {}", err), + } + } +} diff --git a/crates/api_clients/muapi_client/src/error/muapi_specific_api_error.rs b/crates/api_clients/muapi_client/src/error/muapi_specific_api_error.rs new file mode 100644 index 0000000000..37db97df10 --- /dev/null +++ b/crates/api_clients/muapi_client/src/error/muapi_specific_api_error.rs @@ -0,0 +1,18 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum MuapiSpecificApiError { + /// Invalid API key. + InvalidApiKey, +} + +impl Error for MuapiSpecificApiError {} + +impl Display for MuapiSpecificApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidApiKey => write!(f, "Unauthorized: invalid API key."), + } + } +} diff --git a/crates/api_clients/muapi_client/src/lib.rs b/crates/api_clients/muapi_client/src/lib.rs new file mode 100644 index 0000000000..2d9dbdc26d --- /dev/null +++ b/crates/api_clients/muapi_client/src/lib.rs @@ -0,0 +1,7 @@ +#[cfg(test)] +pub (crate) mod test_utils; + +pub mod creds; +pub mod error; +pub mod requests; +pub mod utils; diff --git a/crates/api_clients/muapi_client/src/requests/mod.rs b/crates/api_clients/muapi_client/src/requests/mod.rs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/crates/api_clients/muapi_client/src/test_utils/get_test_cookies.rs b/crates/api_clients/muapi_client/src/test_utils/get_test_cookies.rs new file mode 100644 index 0000000000..14ad9fecba --- /dev/null +++ b/crates/api_clients/muapi_client/src/test_utils/get_test_cookies.rs @@ -0,0 +1,9 @@ +use errors::AnyhowResult; +use std::fs::read_to_string; + +#[cfg(test)] +pub fn get_test_cookies() -> AnyhowResult { + let cookies = read_to_string("/Users/bt/Artcraft/credentials/muapi_cookies.txt")?; + let cookies = cookies.trim().to_string(); + Ok(cookies) +} diff --git a/crates/api_clients/muapi_client/src/test_utils/mod.rs b/crates/api_clients/muapi_client/src/test_utils/mod.rs new file mode 100644 index 0000000000..54c16f3413 --- /dev/null +++ b/crates/api_clients/muapi_client/src/test_utils/mod.rs @@ -0,0 +1,5 @@ +#[cfg(test)] +pub (crate) mod get_test_cookies; + +#[cfg(test)] +pub (crate) mod setup_test_logging; diff --git a/crates/api_clients/muapi_client/src/test_utils/setup_test_logging.rs b/crates/api_clients/muapi_client/src/test_utils/setup_test_logging.rs new file mode 100644 index 0000000000..8c8a1ba71a --- /dev/null +++ b/crates/api_clients/muapi_client/src/test_utils/setup_test_logging.rs @@ -0,0 +1,52 @@ +use chrono::Local; +use env_logger::Builder; +use log::{debug, error, info, trace, warn, LevelFilter}; +use std::io::Write; + +const RUST_LOG: &str = "RUST_LOG"; + +pub fn setup_test_logging(level: LevelFilter) { + let level_str = get_level_str(level); + + println!("Previous log level: {:?}", env::var(RUST_LOG)); + + let is_unsafe = env::set_var(RUST_LOG, level_str); + + if is_unsafe.is_none() { + println!("Unsafely setting Rust Log Level..."); + unsafe { std::env::set_var(RUST_LOG, level_str) } + } + + println!("Log level: {:?}", env::var(RUST_LOG)); + + Builder::new() + .is_test(true) + .format(|buf, record| { + writeln!(buf, + "{} [{}] - {}", + Local::now().format("%H:%M:%S%.6f"), + record.level(), + record.args() + ) + }) + .filter(None, level) + .filter_level(level) + .init(); + + trace!("Test trace log"); + debug!("Test debug log"); + info!("Test info log"); + warn!("Test warn log"); + error!("Test error log"); +} + +fn get_level_str(level: LevelFilter) -> &'static str { + match level { + LevelFilter::Error => "error", + LevelFilter::Warn => "warn", + LevelFilter::Info => "info", + LevelFilter::Debug => "debug", + LevelFilter::Trace => "trace", + LevelFilter::Off => "off", + } +} diff --git a/crates/api_clients/muapi_client/src/utils/common_headers.rs b/crates/api_clients/muapi_client/src/utils/common_headers.rs new file mode 100644 index 0000000000..d10f62ab06 --- /dev/null +++ b/crates/api_clients/muapi_client/src/utils/common_headers.rs @@ -0,0 +1,2 @@ +pub const FIREFOX_USER_AGENT: &str = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) Gecko/20100101 Firefox/147.0"; diff --git a/crates/api_clients/muapi_client/src/utils/mod.rs b/crates/api_clients/muapi_client/src/utils/mod.rs new file mode 100644 index 0000000000..aae60bbf75 --- /dev/null +++ b/crates/api_clients/muapi_client/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod common_headers; diff --git a/crates/api_clients/seedance2pro/Cargo.toml b/crates/api_clients/seedance2pro/Cargo.toml index 77addaa640..8b59c8cc4d 100644 --- a/crates/api_clients/seedance2pro/Cargo.toml +++ b/crates/api_clients/seedance2pro/Cargo.toml @@ -31,7 +31,6 @@ wreq.workspace = true url.workspace = true [dev-dependencies] -chrono.workspace = true env.workspace = true env_logger.workspace = true testing = { path = "../../lib/testing" } diff --git a/crates/api_clients/seedance2pro/src/error/seedance2pro_error.rs b/crates/api_clients/seedance2pro/src/error/seedance2pro_error.rs index 5d876b5e34..b0f8c5a9b6 100644 --- a/crates/api_clients/seedance2pro/src/error/seedance2pro_error.rs +++ b/crates/api_clients/seedance2pro/src/error/seedance2pro_error.rs @@ -14,10 +14,17 @@ pub enum Seedance2ProError { impl Seedance2ProError { pub fn is_having_downtime_issues(&self) -> bool { match self { - Self::ApiGeneric(Seedance2ProGenericApiError::Http502ErrorBadGateway(_)) => true, Self::ApiGeneric(Seedance2ProGenericApiError::CloudflareError(CloudflareError::BadGateway502)) => true, Self::ApiGeneric(Seedance2ProGenericApiError::CloudflareError(CloudflareError::GatewayTimeout504)) => true, Self::ApiGeneric(Seedance2ProGenericApiError::CloudflareError(CloudflareError::TimeoutOccurred524)) => true, + Self::ApiGeneric(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body }) => { + match status_code.as_u16() { + 502 => true, + 504 => true, + 524 => true, + _ => false, + } + }, _ => false, } } diff --git a/crates/api_clients/seedance2pro/src/error/seedance2pro_generic_api_error.rs b/crates/api_clients/seedance2pro/src/error/seedance2pro_generic_api_error.rs index 6f0db74ced..6b7572e327 100644 --- a/crates/api_clients/seedance2pro/src/error/seedance2pro_generic_api_error.rs +++ b/crates/api_clients/seedance2pro/src/error/seedance2pro_generic_api_error.rs @@ -8,10 +8,6 @@ pub enum Seedance2ProGenericApiError { /// Specific Cloudflare errors. CloudflareError(CloudflareError), - /// HTTP 502 Bad Gateway. - /// We preserve the message for debugging. - Http502ErrorBadGateway(String), - /// serde_json::Error, likely from JSON deserialization schema mismatch. /// Includes the original body. SerdeResponseParseErrorWithBody(serde_json::Error, String), @@ -39,7 +35,6 @@ impl Display for Seedance2ProGenericApiError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Self::CloudflareError(err) => write!(f, "Cloudflare error: {}", err), - Self::Http502ErrorBadGateway(msg) => write!(f, "HTTP 502 Bad Gateway: {}", msg), Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body), Self::SerdeParseErrorWithBodyOnNon200(err, body) => write!(f, "Failed to parse non-200 response body: {:?}. Body: {}", err, body), Self::UncategorizedBadResponse(msg) => write!(f, "Uncategorized bad response: {}", msg),