wp_mini/
client.rs

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