ik-mini 0.2.0-alpha.5

Minimal async API Wrapper for IK | Only Reader/Public API | Extremely minimal.
Documentation
//! The main module for the Inkitt API client.
//!
//! It contains the primary `InkittClient`, which serves as the entry point for all API
//! interactions. It also includes the internal `InkittRequestBuilder` for constructing
//! and executing API calls, and helper functions for handling responses.

use crate::endpoints::story::StoryClient;
use crate::error::{ApiErrorResponse, InkittError};
use crate::types::LoginResponse;
use bytes::Bytes;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, USER_AGENT};
use reqwest::Client as ReqwestClient;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
// =================================================================================================
// InkittClientBuilder
// =================================================================================================

/// A builder for creating a `InkittClient` with custom configuration.
#[derive(Default)]
pub struct InkittClientBuilder {
    client: Option<ReqwestClient>,
    user_agent: Option<String>,
    headers: Option<HeaderMap>,
}

impl InkittClientBuilder {
    /// Provide a pre-configured `reqwest::Client`.
    /// If this is used, any other configurations like `.user_agent()` or `.header()` will be ignored,
    /// as the provided client is assumed to be fully configured.
    pub fn reqwest_client(mut self, client: ReqwestClient) -> Self {
        self.client = Some(client);
        self
    }

    /// Set a custom User-Agent string for all requests.
    pub fn user_agent(mut self, user_agent: &str) -> Self {
        self.user_agent = Some(user_agent.to_string());
        self
    }

    /// Add a single custom header to be sent with all requests.
    pub fn header(mut self, key: HeaderName, value: HeaderValue) -> Self {
        self.headers
            .get_or_insert_with(HeaderMap::new)
            .insert(key, value);
        self
    }

    /// Builds the `InkittClient`.
    ///
    /// If a `reqwest::Client` was not provided via the builder, a new default one will be created.
    pub fn build(self) -> InkittClient {
        let http_client = match self.client {
            // If a client was provided, use it directly.
            Some(client) => client,
            // Otherwise, build a new client using the builder's settings.
            None => {
                let mut headers = self.headers.unwrap_or_default();

                // Set the User-Agent, preferring the custom one, otherwise use the default.
                let ua_string = self.user_agent.unwrap_or_else(||
                    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36".to_string()

                );

                // Insert the user-agent header, it will override any existing one in the map.
                headers.insert(
                    USER_AGENT,
                    HeaderValue::from_str(&ua_string).expect("Invalid User-Agent string"),
                );

                let mut client_builder = ReqwestClient::builder().default_headers(headers);

                #[cfg(not(target_arch = "wasm32"))]
                {
                    client_builder = client_builder.cookie_store(true);
                }

                client_builder
                    .build()
                    .expect("Failed to build reqwest client")
            }
        };

        let auth_flag = Arc::new(AtomicBool::new(false));
        let auth_token = Arc::new(RwLock::new(None));

        InkittClient {
            story: StoryClient {
                http: http_client.clone(),
                is_authenticated: auth_flag.clone(),
                auth_token: auth_token.clone(),
            },
            http: http_client,
            is_authenticated: auth_flag,
            auth_token,
        }
    }
}

/// The main asynchronous client for interacting with the Inkitt API.
///
/// This client holds the HTTP connection, manages authentication state, and provides
/// access to categorized sub-clients for different parts of the API.
pub struct InkittClient {
    /// The underlying `reqwest` client used for all HTTP requests.
    http: reqwest::Client,
    /// An atomically-managed boolean flag to track authentication status.
    is_authenticated: Arc<AtomicBool>,
    /// The Bearer token stored safely for concurrent access.
    auth_token: Arc<RwLock<Option<String>>>,

    /// Provides access to story and part-related API endpoints.
    pub story: StoryClient,
}

impl InkittClient {
    /// Creates a new `InkittClient` with default settings.
    ///
    /// This is now a convenience method that uses the builder.
    pub fn new() -> Self {
        InkittClientBuilder::default().build()
    }

    /// Creates a new builder for configuring a `InkittClient`.
    ///
    /// This is the new entry point for custom client creation.
    pub fn builder() -> InkittClientBuilder {
        InkittClientBuilder::default()
    }

    /// Authenticates the client using an email and password via the Inkitt API.
    ///
    /// # Arguments
    /// * `email` - The Inkitt account email address.
    /// * `password` - The Inkitt password.
    pub async fn authenticate(
        &self,
        email: &str,
        password: &str,
    ) -> Result<LoginResponse, InkittError> {
        let url = "https://harry.inkitt.com/2/current_user/login_or_signup";

        let payload = serde_json::json!({
            "session_params": {
                "email": email,
                "password": password
            }
        });

        let response = self.http.post(url).json(&payload).send().await?;

        if !response.status().is_success() {
            self.is_authenticated.store(false, Ordering::SeqCst);
            // Clear token on failure
            let mut token_lock = self.auth_token.write().unwrap();
            *token_lock = None;
            return Err(InkittError::AuthenticationFailed);
        }

        // Deserialize the JSON into our struct
        let login_data: LoginResponse = response
            .json()
            .await
            .map_err(|_| InkittError::AuthenticationFailed)?;

        // Store the secret_token in the client state
        {
            let mut token_lock = self.auth_token.write().unwrap();
            *token_lock = Some(login_data.response.secret_token.clone());
        }

        // Update boolean flag
        self.is_authenticated.store(true, Ordering::SeqCst);

        // Return the user object
        Ok(login_data)
    }

    /// Deauthenticates the client by logging out from Inkitt.
    ///
    /// This method sends a request to the logout endpoint, which invalidates the session
    /// cookies. It then sets the client's internal authentication state to `false`.
    ///
    /// # Returns
    /// An empty `Ok(())` on successful logout.
    ///
    /// # Errors
    /// Returns a `InkittError` if the HTTP request fails.
    pub async fn deauthenticate(&self) -> Result<(), InkittError> {
        let url = "https://www.Inkitt.com/logout";

        // Send a GET request to the logout URL. The reqwest client's cookie store
        // will automatically handle the updated (cleared) session cookies from the response.
        self.http.get(url).send().await?;

        // Set the local authentication flag to false.
        self.is_authenticated.store(false, Ordering::SeqCst);
        Ok(())
    }

    /// Checks if the client has been successfully authenticated.
    ///
    /// # Returns
    /// `true` if `authenticate` has been called successfully, `false` otherwise.
    pub fn is_authenticated(&self) -> bool {
        self.is_authenticated.load(Ordering::SeqCst)
    }
}

/// Provides a default implementation for `InkittClient`.
///
/// This is equivalent to calling `InkittClient::new()`.
impl Default for InkittClient {
    fn default() -> Self {
        Self::new()
    }
}

/// A private helper function to process a `reqwest::Response`.
///
/// If the response status is successful, it deserializes the JSON body into type `T`.
/// Otherwise, it attempts to parse a specific `ApiErrorResponse` format from the body.
async fn handle_response<T: serde::de::DeserializeOwned>(
    response: reqwest::Response,
) -> Result<T, InkittError> {
    if response.status().is_success() {
        let json = response.json::<T>().await?;
        Ok(json)
    } else {
        let error_response = response.json::<ApiErrorResponse>().await?;
        Err(error_response.into())
    }
}

// =================================================================================================

/// An internal builder for constructing and executing API requests.
///
/// This struct uses a fluent, chainable interface to build up an API call
/// with its path, parameters, fields, and authentication requirements before sending it.
pub(crate) struct InkittRequestBuilder<'a> {
    client: &'a reqwest::Client,
    is_authenticated: &'a Arc<AtomicBool>,
    auth_token: &'a Arc<RwLock<Option<String>>>,
    method: reqwest::Method,
    path: String,
    params: Vec<(&'static str, String)>,
    auth_required: bool,
}

impl<'a> InkittRequestBuilder<'a> {
    /// Creates a new request builder.
    pub(crate) fn new(
        client: &'a reqwest::Client,
        is_authenticated: &'a Arc<AtomicBool>,
        auth_token: &'a Arc<RwLock<Option<String>>>,
        method: reqwest::Method,
        path: &str,
    ) -> Self {
        Self {
            client,
            is_authenticated,
            auth_token,
            method,
            path: path.to_string(),
            params: Vec::new(),
            auth_required: false,
        }
    }

    /// A private helper to check for endpoint authentication before sending a request.
    fn check_endpoint_auth(&self) -> Result<(), InkittError> {
        if self.auth_required && !self.is_authenticated.load(Ordering::SeqCst) {
            return Err(InkittError::AuthenticationRequired {
                field: "Endpoint".to_string(),
                context: format!("The endpoint at '{}' requires authentication.", self.path),
            });
        }
        Ok(())
    }

    /// Marks the entire request as requiring authentication.
    ///
    /// If this is set, the request will fail with an error if the client is not authenticated.
    pub(crate) fn requires_auth(mut self) -> Self {
        self.auth_required = true;
        self
    }

    /// Adds a query parameter to the request from an `Option`.
    ///
    /// If the value is `Some`, the parameter is added. If `None`, it's ignored.
    pub(crate) fn maybe_param<T: ToString>(mut self, key: &'static str, value: Option<T>) -> Self {
        if let Some(val) = value {
            self.params.push((key, val.to_string()));
        }
        self
    }

    /// Adds a query parameter to the request.
    pub(crate) fn param<T: ToString>(mut self, key: &'static str, value: Option<T>) -> Self {
        if let Some(val) = value {
            self.params.push((key, val.to_string()));
        }
        self
    }

    /// Executes the request and deserializes the JSON response into a specified type `T`.
    pub(crate) async fn execute<T: serde::de::DeserializeOwned>(self) -> Result<T, InkittError> {
        self.check_endpoint_auth()?;

        let url = format!("https://harry.inkitt.com{}", self.path);

        // Start building the request
        let mut request_builder = self.client.request(self.method, &url).query(&self.params);

        // Acquire a read lock. This is fast and thread-safe.
        if let Ok(lock) = self.auth_token.read() 
            && let Some(token) = &*lock {
                // Inkitt uses "Bearer <token>" standard
                request_builder =
                    request_builder.header("Authorization", format!("Bearer {}", token));
            }
        

        let response = request_builder.send().await?;
        handle_response(response).await
    }

    /// Executes the request and returns the raw response body as a `String`.
    pub(crate) async fn execute_raw_text(self) -> Result<String, InkittError> {
        self.check_endpoint_auth()?;

        let url = format!("https://harry.inkitt.com{}", self.path);
        let response = self
            .client
            .request(self.method, &url)
            .query(&self.params)
            .send()
            .await?;

        if response.status().is_success() {
            Ok(response.text().await?)
        } else {
            let error_response = response.json::<ApiErrorResponse>().await?;
            Err(error_response.into())
        }
    }

    /// Executes the request and returns the raw response body as `Bytes`.
    ///
    /// This method is ideal for downloading files or other binary content.
    pub(crate) async fn execute_bytes(self) -> Result<Bytes, InkittError> {
        self.check_endpoint_auth()?;

        let url = format!("https://harry.inkitt.com{}", self.path);
        let response = self
            .client
            .request(self.method, &url)
            .query(&self.params)
            .send()
            .await?;

        if response.status().is_success() {
            Ok(response.bytes().await?)
        } else {
            let error_response = response.json::<ApiErrorResponse>().await?;
            Err(error_response.into())
        }
    }
}