ik_mini/
client.rs

1//! The main module for the Inkitt API client.
2//!
3//! It contains the primary `InkittClient`, which serves as the entry point for all API
4//! interactions. It also includes the internal `InkittRequestBuilder` for constructing
5//! and executing API calls, and helper functions for handling responses.
6
7use crate::endpoints::story::StoryClient;
8use crate::error::{ApiErrorResponse, InkittError};
9use crate::types::LoginResponse;
10use bytes::Bytes;
11use reqwest::header::{HeaderMap, HeaderName, HeaderValue, USER_AGENT};
12use reqwest::Client as ReqwestClient;
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::{Arc, RwLock};
15// =================================================================================================
16// InkittClientBuilder
17// =================================================================================================
18
19/// A builder for creating a `InkittClient` with custom configuration.
20#[derive(Default)]
21pub struct InkittClientBuilder {
22    client: Option<ReqwestClient>,
23    user_agent: Option<String>,
24    headers: Option<HeaderMap>,
25}
26
27impl InkittClientBuilder {
28    /// Provide a pre-configured `reqwest::Client`.
29    /// If this is used, any other configurations like `.user_agent()` or `.header()` will be ignored,
30    /// as the provided client is assumed to be fully configured.
31    pub fn reqwest_client(mut self, client: ReqwestClient) -> Self {
32        self.client = Some(client);
33        self
34    }
35
36    /// Set a custom User-Agent string for all requests.
37    pub fn user_agent(mut self, user_agent: &str) -> Self {
38        self.user_agent = Some(user_agent.to_string());
39        self
40    }
41
42    /// Add a single custom header to be sent with all requests.
43    pub fn header(mut self, key: HeaderName, value: HeaderValue) -> Self {
44        self.headers
45            .get_or_insert_with(HeaderMap::new)
46            .insert(key, value);
47        self
48    }
49
50    /// Builds the `InkittClient`.
51    ///
52    /// If a `reqwest::Client` was not provided via the builder, a new default one will be created.
53    pub fn build(self) -> InkittClient {
54        let http_client = match self.client {
55            // If a client was provided, use it directly.
56            Some(client) => client,
57            // Otherwise, build a new client using the builder's settings.
58            None => {
59                let mut headers = self.headers.unwrap_or_default();
60
61                // Set the User-Agent, preferring the custom one, otherwise use the default.
62                let ua_string = self.user_agent.unwrap_or_else(||
63                    "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()
64                );
65
66                // Insert the user-agent header, it will override any existing one in the map.
67                headers.insert(
68                    USER_AGENT,
69                    HeaderValue::from_str(&ua_string).expect("Invalid User-Agent string"),
70                );
71
72                let mut client_builder = ReqwestClient::builder().default_headers(headers);
73
74                #[cfg(not(target_arch = "wasm32"))]
75                {
76                    client_builder = client_builder.cookie_store(true);
77                }
78
79                client_builder
80                    .build()
81                    .expect("Failed to build reqwest client")
82            }
83        };
84
85        let auth_flag = Arc::new(AtomicBool::new(false));
86        let auth_token = Arc::new(RwLock::new(None));
87
88        InkittClient {
89            story: StoryClient {
90                http: http_client.clone(),
91                is_authenticated: auth_flag.clone(),
92                auth_token: auth_token.clone(),
93            },
94            http: http_client,
95            is_authenticated: auth_flag,
96            auth_token,
97        }
98    }
99}
100
101/// The main asynchronous client for interacting with the Inkitt API.
102///
103/// This client holds the HTTP connection, manages authentication state, and provides
104/// access to categorized sub-clients for different parts of the API.
105pub struct InkittClient {
106    /// The underlying `reqwest` client used for all HTTP requests.
107    http: reqwest::Client,
108    /// An atomically-managed boolean flag to track authentication status.
109    is_authenticated: Arc<AtomicBool>,
110    /// The Bearer token stored safely for concurrent access.
111    auth_token: Arc<RwLock<Option<String>>>,
112
113    /// Provides access to story and part-related API endpoints.
114    pub story: StoryClient,
115}
116
117impl InkittClient {
118    /// Creates a new `InkittClient` with default settings.
119    ///
120    /// This is now a convenience method that uses the builder.
121    pub fn new() -> Self {
122        InkittClientBuilder::default().build()
123    }
124
125    /// Creates a new builder for configuring a `InkittClient`.
126    ///
127    /// This is the new entry point for custom client creation.
128    pub fn builder() -> InkittClientBuilder {
129        InkittClientBuilder::default()
130    }
131
132    /// Authenticates the client using an email and password via the Inkitt API.
133    ///
134    /// # Arguments
135    /// * `email` - The Inkitt account email address.
136    /// * `password` - The Inkitt password.
137    pub async fn authenticate(
138        &self,
139        email: &str,
140        password: &str,
141    ) -> Result<LoginResponse, InkittError> {
142        let url = "https://harry.inkitt.com/2/current_user/login_or_signup";
143
144        let payload = serde_json::json!({
145            "session_params": {
146                "email": email,
147                "password": password
148            }
149        });
150
151        let response = self.http.post(url).json(&payload).send().await?;
152
153        if !response.status().is_success() {
154            self.is_authenticated.store(false, Ordering::SeqCst);
155            // Clear token on failure
156            let mut token_lock = self.auth_token.write().unwrap();
157            *token_lock = None;
158            return Err(InkittError::AuthenticationFailed);
159        }
160
161        // Deserialize the JSON into our struct
162        let login_data: LoginResponse = response
163            .json()
164            .await
165            .map_err(|_| InkittError::AuthenticationFailed)?;
166
167        // Store the secret_token in the client state
168        {
169            let mut token_lock = self.auth_token.write().unwrap();
170            *token_lock = Some(login_data.response.secret_token.clone());
171        }
172
173        // Update boolean flag
174        self.is_authenticated.store(true, Ordering::SeqCst);
175
176        // Return the user object
177        Ok(login_data)
178    }
179
180    /// Deauthenticates the client by logging out from Inkitt.
181    ///
182    /// This method sends a request to the logout endpoint, which invalidates the session
183    /// cookies. It then sets the client's internal authentication state to `false`.
184    ///
185    /// # Returns
186    /// An empty `Ok(())` on successful logout.
187    ///
188    /// # Errors
189    /// Returns a `InkittError` if the HTTP request fails.
190    pub async fn deauthenticate(&self) -> Result<(), InkittError> {
191        let url = "https://www.Inkitt.com/logout";
192
193        // Send a GET request to the logout URL. The reqwest client's cookie store
194        // will automatically handle the updated (cleared) session cookies from the response.
195        self.http.get(url).send().await?;
196
197        // Set the local authentication flag to false.
198        self.is_authenticated.store(false, Ordering::SeqCst);
199        Ok(())
200    }
201
202    /// Checks if the client has been successfully authenticated.
203    ///
204    /// # Returns
205    /// `true` if `authenticate` has been called successfully, `false` otherwise.
206    pub fn is_authenticated(&self) -> bool {
207        self.is_authenticated.load(Ordering::SeqCst)
208    }
209}
210
211/// Provides a default implementation for `InkittClient`.
212///
213/// This is equivalent to calling `InkittClient::new()`.
214impl Default for InkittClient {
215    fn default() -> Self {
216        Self::new()
217    }
218}
219
220/// A private helper function to process a `reqwest::Response`.
221///
222/// If the response status is successful, it deserializes the JSON body into type `T`.
223/// Otherwise, it attempts to parse a specific `ApiErrorResponse` format from the body.
224async fn handle_response<T: serde::de::DeserializeOwned>(
225    response: reqwest::Response,
226) -> Result<T, InkittError> {
227    if response.status().is_success() {
228        let json = response.json::<T>().await?;
229        Ok(json)
230    } else {
231        let error_response = response.json::<ApiErrorResponse>().await?;
232        Err(error_response.into())
233    }
234}
235
236// =================================================================================================
237
238/// An internal builder for constructing and executing API requests.
239///
240/// This struct uses a fluent, chainable interface to build up an API call
241/// with its path, parameters, fields, and authentication requirements before sending it.
242pub(crate) struct InkittRequestBuilder<'a> {
243    client: &'a reqwest::Client,
244    is_authenticated: &'a Arc<AtomicBool>,
245    auth_token: &'a Arc<RwLock<Option<String>>>,
246    method: reqwest::Method,
247    path: String,
248    params: Vec<(&'static str, String)>,
249    auth_required: bool,
250}
251
252impl<'a> InkittRequestBuilder<'a> {
253    /// Creates a new request builder.
254    pub(crate) fn new(
255        client: &'a reqwest::Client,
256        is_authenticated: &'a Arc<AtomicBool>,
257        auth_token: &'a Arc<RwLock<Option<String>>>,
258        method: reqwest::Method,
259        path: &str,
260    ) -> Self {
261        Self {
262            client,
263            is_authenticated,
264            auth_token,
265            method,
266            path: path.to_string(),
267            params: Vec::new(),
268            auth_required: false,
269        }
270    }
271
272    /// A private helper to check for endpoint authentication before sending a request.
273    fn check_endpoint_auth(&self) -> Result<(), InkittError> {
274        if self.auth_required && !self.is_authenticated.load(Ordering::SeqCst) {
275            return Err(InkittError::AuthenticationRequired {
276                field: "Endpoint".to_string(),
277                context: format!("The endpoint at '{}' requires authentication.", self.path),
278            });
279        }
280        Ok(())
281    }
282
283    /// Marks the entire request as requiring authentication.
284    ///
285    /// If this is set, the request will fail with an error if the client is not authenticated.
286    pub(crate) fn requires_auth(mut self) -> Self {
287        self.auth_required = true;
288        self
289    }
290
291    /// Adds a query parameter to the request from an `Option`.
292    ///
293    /// If the value is `Some`, the parameter is added. If `None`, it's ignored.
294    pub(crate) fn maybe_param<T: ToString>(mut self, key: &'static str, value: Option<T>) -> Self {
295        if let Some(val) = value {
296            self.params.push((key, val.to_string()));
297        }
298        self
299    }
300
301    /// Adds a query parameter to the request.
302    pub(crate) fn param<T: ToString>(mut self, key: &'static str, value: Option<T>) -> Self {
303        if let Some(val) = value {
304            self.params.push((key, val.to_string()));
305        }
306        self
307    }
308
309    /// Executes the request and deserializes the JSON response into a specified type `T`.
310    pub(crate) async fn execute<T: serde::de::DeserializeOwned>(self) -> Result<T, InkittError> {
311        self.check_endpoint_auth()?;
312
313        let url = format!("https://harry.inkitt.com{}", self.path);
314
315        // Start building the request
316        let mut request_builder = self.client.request(self.method, &url).query(&self.params);
317
318        // Acquire a read lock. This is fast and thread-safe.
319        if let Ok(lock) = self.auth_token.read() 
320            && let Some(token) = &*lock {
321                // Inkitt uses "Bearer <token>" standard
322                request_builder =
323                    request_builder.header("Authorization", format!("Bearer {}", token));
324            }
325        
326
327        let response = request_builder.send().await?;
328        handle_response(response).await
329    }
330
331    /// Executes the request and returns the raw response body as a `String`.
332    pub(crate) async fn execute_raw_text(self) -> Result<String, InkittError> {
333        self.check_endpoint_auth()?;
334
335        let url = format!("https://harry.inkitt.com{}", self.path);
336        let response = self
337            .client
338            .request(self.method, &url)
339            .query(&self.params)
340            .send()
341            .await?;
342
343        if response.status().is_success() {
344            Ok(response.text().await?)
345        } else {
346            let error_response = response.json::<ApiErrorResponse>().await?;
347            Err(error_response.into())
348        }
349    }
350
351    /// Executes the request and returns the raw response body as `Bytes`.
352    ///
353    /// This method is ideal for downloading files or other binary content.
354    pub(crate) async fn execute_bytes(self) -> Result<Bytes, InkittError> {
355        self.check_endpoint_auth()?;
356
357        let url = format!("https://harry.inkitt.com{}", self.path);
358        let response = self
359            .client
360            .request(self.method, &url)
361            .query(&self.params)
362            .send()
363            .await?;
364
365        if response.status().is_success() {
366            Ok(response.bytes().await?)
367        } else {
368            let error_response = response.json::<ApiErrorResponse>().await?;
369            Err(error_response.into())
370        }
371    }
372}