#[cfg(feature = "activity")]
use crate::endpoints::ActivityEndpoint;
#[cfg(feature = "airing")]
use crate::endpoints::AiringEndpoint;
#[cfg(feature = "character")]
use crate::endpoints::CharacterEndpoint;
#[cfg(feature = "common")]
use crate::endpoints::CommonEndpoint;
#[cfg(feature = "forum")]
use crate::endpoints::ForumEndpoint;
#[cfg(feature = "media")]
use crate::endpoints::MediaEndpoint;
#[cfg(feature = "medialist")]
use crate::endpoints::MediaListEndpoint;
#[cfg(feature = "notification")]
use crate::endpoints::NotificationEndpoint;
#[cfg(feature = "recommendation")]
use crate::endpoints::RecommendationEndpoint;
#[cfg(feature = "review")]
use crate::endpoints::ReviewEndpoint;
#[cfg(feature = "staff")]
use crate::endpoints::StaffEndpoint;
#[cfg(feature = "studio")]
use crate::endpoints::StudioEndpoint;
#[cfg(feature = "user")]
use crate::endpoints::UserEndpoint;
use crate::errors::AniListError;
use crate::objects::responses::GraphQLResponse;
use crate::utils::{RetryConfig, retry_with_backoff};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use reqwest::{Client, Response, StatusCode};
use serde::Serialize;
use serde_json::{Value, from_value};
use std::borrow::Cow;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
const ANILIST_API_URL: &str = "https://graphql.anilist.co";
const USER_AGENT: &str = concat!("anilist-moe/", env!("CARGO_PKG_VERSION"), " (Rust)");
const CONTENT_TYPE_JSON: &str = "application/json";
const BEARER_PREFIX: &str = "Bearer ";
struct ClientInner {
client: Client,
token: Option<String>,
retry_config: RetryConfig,
base_url: Cow<'static, str>,
}
#[derive(Clone)]
pub struct AniListClient {
inner: Arc<ClientInner>,
}
impl fmt::Debug for AniListClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AniListClient")
.field("base_url", &self.inner.base_url)
.field("has_token", &self.inner.token.is_some())
.field("retry_config", &self.inner.retry_config)
.finish()
}
}
impl AniListClient {
#[must_use]
pub fn new() -> Self {
AniListClientBuilder::new().build()
}
#[must_use]
pub fn with_token(token: impl Into<String>) -> Self {
AniListClientBuilder::new().token(token).build()
}
#[must_use]
pub fn with_retry_config(self, config: RetryConfig) -> Self {
Self {
inner: Arc::new(ClientInner {
client: self.inner.client.clone(),
token: self.inner.token.clone(),
retry_config: config,
base_url: self.inner.base_url.clone(),
}),
}
}
#[must_use]
pub fn with_base_url(self, base_url: impl Into<String>) -> Self {
Self {
inner: Arc::new(ClientInner {
client: self.inner.client.clone(),
token: self.inner.token.clone(),
retry_config: self.inner.retry_config,
base_url: Cow::Owned(base_url.into()),
}),
}
}
#[cfg(feature = "media")]
#[inline]
pub fn media(&self) -> MediaEndpoint {
MediaEndpoint::new(self.clone())
}
#[cfg(feature = "media")]
#[inline]
pub fn anime(&self) -> MediaEndpoint {
self.media()
}
#[cfg(feature = "media")]
#[inline]
pub fn manga(&self) -> MediaEndpoint {
self.media()
}
#[cfg(feature = "medialist")]
#[inline]
pub fn medialist(&self) -> MediaListEndpoint {
MediaListEndpoint::new(self.clone())
}
#[cfg(feature = "character")]
#[inline]
pub fn character(&self) -> CharacterEndpoint {
CharacterEndpoint::new(self.clone())
}
#[cfg(feature = "common")]
#[inline]
pub fn common(&self) -> CommonEndpoint {
CommonEndpoint::new(self.clone())
}
#[cfg(feature = "staff")]
#[inline]
pub fn staff(&self) -> StaffEndpoint {
StaffEndpoint::new(self.clone())
}
#[cfg(feature = "user")]
#[inline]
pub fn user(&self) -> UserEndpoint {
UserEndpoint::new(self.clone())
}
#[cfg(feature = "studio")]
#[inline]
pub fn studio(&self) -> StudioEndpoint {
StudioEndpoint::new(self.clone())
}
#[cfg(feature = "forum")]
#[inline]
pub fn forum(&self) -> ForumEndpoint {
ForumEndpoint::new(self.clone())
}
#[cfg(feature = "activity")]
#[inline]
pub fn activity(&self) -> ActivityEndpoint {
ActivityEndpoint::new(self.clone())
}
#[cfg(feature = "review")]
#[inline]
pub fn review(&self) -> ReviewEndpoint {
ReviewEndpoint::new(self.clone())
}
#[cfg(feature = "recommendation")]
#[inline]
pub fn recommendation(&self) -> RecommendationEndpoint {
RecommendationEndpoint::new(self.clone())
}
#[cfg(feature = "airing")]
#[inline]
pub fn airing(&self) -> AiringEndpoint {
AiringEndpoint::new(self.clone())
}
#[cfg(feature = "notification")]
#[inline]
pub fn notification(&self) -> NotificationEndpoint {
NotificationEndpoint::new(self.clone())
}
pub fn set_token(&mut self, token: &str) {
*self = Self {
inner: Arc::new(ClientInner {
client: self.inner.client.clone(),
token: Some(token.to_string()),
retry_config: self.inner.retry_config,
base_url: self.inner.base_url.clone(),
}),
};
}
pub fn clear_token(&mut self) {
*self = Self {
inner: Arc::new(ClientInner {
client: self.inner.client.clone(),
token: None,
retry_config: self.inner.retry_config,
base_url: self.inner.base_url.clone(),
}),
};
}
#[inline]
pub fn has_token(&self) -> bool {
self.inner.token.is_some()
}
#[inline]
pub fn retry_config(&self) -> RetryConfig {
self.inner.retry_config
}
pub async fn query<V: Serialize>(
&self,
query: &'static str,
variables: Option<&V>,
) -> Result<Value, AniListError> {
self.execute_query(query, variables).await
}
pub async fn fetch<T, V>(
&self,
query: &'static str,
variables: Option<&V>,
) -> Result<T, AniListError>
where
T: serde::de::DeserializeOwned,
V: Serialize,
{
let response_data = self.execute_query(query, variables).await?;
let wrapper: GraphQLResponse<T> =
from_value(response_data).map_err(|e| AniListError::ParseError {
message: format!("Failed to deserialize response: {}", e),
})?;
Ok(wrapper.data)
}
async fn execute_query<V: Serialize>(
&self,
query: &'static str,
variables: Option<&V>,
) -> Result<Value, AniListError> {
retry_with_backoff(
|| async { self.raw_query(query, variables).await },
self.inner.retry_config,
)
.await
}
async fn raw_query<V: Serialize>(
&self,
query: &'static str,
variables: Option<&V>,
) -> Result<Value, AniListError> {
let body = RequestBody { query, variables };
let mut request = self
.inner
.client
.post(self.inner.base_url.as_ref())
.header("Content-Type", CONTENT_TYPE_JSON);
if let Some(token) = &self.inner.token {
let mut auth_header = String::with_capacity(BEARER_PREFIX.len() + token.len());
auth_header.push_str(BEARER_PREFIX);
auth_header.push_str(token);
request = request.header("Authorization", auth_header);
}
#[cfg(feature = "tracing")]
let span = tracing::info_span!("ani_list_request");
#[cfg(feature = "tracing")]
use tracing::Instrument;
let response_fut = async {
#[cfg(feature = "tracing")]
{
let variables_str = variables
.map(|v| serde_json::to_string_pretty(v).unwrap_or_else(|_| "{}".to_string()))
.unwrap_or_else(|| "None".to_string());
let full_name = std::any::type_name::<V>();
let type_name = full_name.split("::").last().unwrap_or(full_name);
tracing::info!(
"Sending AniList request:\nURL: {}\nVariables Struct: {}\nVariables Payload:\n{}",
self.inner.base_url,
type_name,
variables_str
);
}
let response = request.json(&body).send().await?;
let _status = response.status();
crate::trace_info!(status = _status.as_u16(), "HTTP response received");
self.handle_response(response).await
};
#[cfg(feature = "tracing")]
{
response_fut.instrument(span).await
}
#[cfg(not(feature = "tracing"))]
{
response_fut.await
}
}
async fn handle_response(&self, response: Response) -> Result<Value, AniListError> {
let status = response.status();
if status.is_success() {
let json: Value = response.json().await?;
#[cfg(feature = "tracing")]
{
let pretty_response =
serde_json::to_string_pretty(&json).unwrap_or_else(|_| "{}".to_string());
crate::trace_info!("Response body received:\n{}", pretty_response);
}
let res = self.handle_graphql_errors(json);
if let Err(ref _e) = res {
crate::trace_error!(error = %_e, "GraphQL query returned errors");
}
res
} else {
let _err = self.handle_http_error(status, response).await;
crate::trace_error!(error = %_err, status = status.as_u16(), "HTTP request failed");
Err(_err)
}
}
async fn handle_http_error(&self, status: StatusCode, response: Response) -> AniListError {
if status.as_u16() == 429 {
return self.parse_rate_limit_error(response);
}
let body = response
.text()
.await
.unwrap_or_else(|_| "Unknown Error".to_string());
if status.as_u16() == 503 || crate::errors::detect_maintenance(&body) {
return AniListError::Maintenance { message: body };
}
match status.as_u16() {
400 => {
if let Ok(json) = serde_json::from_str::<Value>(&body)
&& json.get("errors").is_some()
&& let Err(graphql_err) = self.handle_graphql_errors(json)
{
return graphql_err;
}
AniListError::BadRequest { message: body }
}
401 => AniListError::AuthenticationRequired,
403 => AniListError::AccessDenied,
404 => AniListError::NotFound,
500..=599 => AniListError::ServerError {
status: status.as_u16(),
message: body,
},
_ => AniListError::ServerError {
status: status.as_u16(),
message: body,
},
}
}
fn parse_rate_limit_error(&self, response: Response) -> AniListError {
let headers = response.headers();
let get_header = |key: &str| headers.get(key).and_then(|v| v.to_str().ok());
if let (Some(limit), Some(remaining), Some(reset), Some(retry_after)) = (
get_header("X-RateLimit-Limit").and_then(|s| s.parse().ok()),
get_header("X-RateLimit-Remaining").and_then(|s| s.parse().ok()),
get_header("X-RateLimit-Reset").and_then(|s| s.parse().ok()),
get_header("Retry-After").and_then(|s| s.parse().ok()),
) {
AniListError::RateLimit {
limit,
remaining,
reset_at: reset,
retry_after,
}
} else {
AniListError::RateLimitSimple
}
}
fn handle_graphql_errors(&self, json: Value) -> Result<Value, AniListError> {
if let Some(errors) = json.get("errors") {
let error_message = if let Some(arr) = errors.as_array() {
let estimated_size: usize = arr
.iter()
.map(|e| {
e.get("message")
.and_then(|m| m.as_str())
.map(|s| s.len() + 2)
.unwrap_or(15)
})
.sum();
let mut result = String::with_capacity(estimated_size);
for (i, e) in arr.iter().enumerate() {
if i > 0 {
result.push_str(", ");
}
result.push_str(
e.get("message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error"),
);
}
result
} else {
errors.to_string()
};
let lower = error_message.to_lowercase();
if lower.contains("rate limit") || lower.contains("too many requests") {
return Err(AniListError::BurstLimit);
}
if crate::errors::detect_maintenance(&error_message) {
return Err(AniListError::Maintenance {
message: error_message,
});
}
let parsed_errors: Vec<crate::errors::GraphQLErrorItem> =
serde_json::from_value(errors.clone()).unwrap_or_default();
Err(AniListError::GraphQL {
message: error_message,
errors: parsed_errors,
})
} else {
Ok(json)
}
}
}
#[derive(Serialize)]
struct RequestBody<'a, V: Serialize> {
query: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
variables: Option<&'a V>,
}
impl Default for AniListClient {
fn default() -> Self {
Self::new()
}
}
#[derive(Default, Debug, Clone)]
pub struct AniListClientBuilder {
token: Option<String>,
retry_config: Option<RetryConfig>,
base_url: Option<String>,
timeout: Option<Duration>,
headers: HeaderMap,
}
impl AniListClientBuilder {
#[must_use]
pub fn new() -> Self {
Self {
token: None,
retry_config: None,
base_url: None,
timeout: None,
headers: HeaderMap::new(),
}
}
#[must_use]
pub fn token(mut self, token: impl Into<String>) -> Self {
self.token = Some(token.into());
self
}
#[must_use]
pub fn retry_config(mut self, config: RetryConfig) -> Self {
self.retry_config = Some(config);
self
}
#[must_use]
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = Some(base_url.into());
self
}
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
#[must_use]
pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
self.headers.insert(name, value);
self
}
#[must_use]
pub fn headers(mut self, headers: HeaderMap) -> Self {
self.headers.extend(headers);
self
}
#[must_use]
pub fn build(self) -> AniListClient {
let mut client_builder = Client::builder()
.user_agent(USER_AGENT)
.pool_max_idle_per_host(10)
.tcp_nodelay(true);
if let Some(timeout) = self.timeout {
client_builder = client_builder.timeout(timeout);
} else {
client_builder = client_builder.timeout(Duration::from_secs(30));
}
if !self.headers.is_empty() {
client_builder = client_builder.default_headers(self.headers);
}
let client = client_builder.build().expect("Failed to build HTTP client");
AniListClient {
inner: Arc::new(ClientInner {
client,
token: self.token,
retry_config: self.retry_config.unwrap_or_default(),
base_url: self
.base_url
.map(Cow::Owned)
.unwrap_or_else(|| Cow::Borrowed(ANILIST_API_URL)),
}),
}
}
}