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;
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// 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(USER_AGENT, HeaderValue::from_str(&ua_string).expect("Invalid User-Agent string"));
70
71                let mut client_builder = ReqwestClient::builder()
72                    .default_headers(headers);
73
74                #[cfg(not(target_arch = "wasm32"))]
75                {
76                    client_builder = client_builder.cookie_store(true);
77                }
78 
79                client_builder.build()
80                    .expect("Failed to build reqwest client")
81            }
82        };
83
84        // The rest of the logic remains the same
85        let auth_flag = Arc::new(AtomicBool::new(false));
86        WattpadClient {
87            user: UserClient {
88                http: http_client.clone(),
89                is_authenticated: auth_flag.clone(),
90            },
91            story: StoryClient {
92                http: http_client.clone(),
93                is_authenticated: auth_flag.clone(),
94            },
95            http: http_client,
96            is_authenticated: auth_flag,
97        }
98    }
99}
100
101/// The main asynchronous client for interacting with the Wattpad 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 WattpadClient {
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    /// Provides access to user-related API endpoints.
111    pub user: UserClient,
112    /// Provides access to story and part-related API endpoints.
113    pub story: StoryClient,
114}
115
116impl WattpadClient {
117    /// Creates a new `WattpadClient` with default settings.
118    ///
119    /// This is now a convenience method that uses the builder.
120    pub fn new() -> Self {
121        WattpadClientBuilder::default().build()
122    }
123
124    /// Creates a new builder for configuring a `WattpadClient`.
125    ///
126    /// This is the new entry point for custom client creation.
127    pub fn builder() -> WattpadClientBuilder {
128        WattpadClientBuilder::default()
129    }
130
131    /// Authenticates the client using a username and password.
132    ///
133    /// On a successful login, the API returns session cookies which are automatically
134    /// stored in the client's cookie store for use in subsequent requests.
135    ///
136    /// # Arguments
137    /// * `username` - The Wattpad username.
138    /// * `password` - The Wattpad password.
139    ///
140    /// # Returns
141    /// An empty `Ok(())` on successful authentication.
142    ///
143    /// # Errors
144    /// Returns `WattpadError::AuthenticationFailed` if login is unsuccessful.
145    pub async fn authenticate(&self, username: &str, password: &str) -> Result<(), WattpadError> {
146        let url = "https://www.wattpad.com/auth/login?&_data=routes%2Fauth.login";
147
148        let mut payload = HashMap::new();
149        payload.insert("username", username);
150        payload.insert("password", password);
151
152        let response = self.http.post(url).form(&payload).send().await?;
153
154        // --- NATIVE-SPECIFIC LOGIC ---
155        // For native builds, we verify that cookies were actually returned.
156        #[cfg(not(target_arch = "wasm32"))]
157        {
158            if response.cookies().next().is_none() {
159                self.is_authenticated.store(false, Ordering::SeqCst);
160                return Err(WattpadError::AuthenticationFailed);
161            }
162        }
163
164        // --- WASM-SPECIFIC LOGIC ---
165        // For WASM, the browser handles cookies. We just check for a success status.
166        #[cfg(target_arch = "wasm32")]
167        {
168            if !response.status().is_success() {
169                self.is_authenticated.store(false, Ordering::SeqCst);
170                return Err(WattpadError::AuthenticationFailed);
171            }
172        }
173
174        self.is_authenticated.store(true, Ordering::SeqCst);
175        Ok(())
176    }
177
178    /// Deauthenticates the client by logging out from Wattpad.
179    ///
180    /// This method sends a request to the logout endpoint, which invalidates the session
181    /// cookies. It then sets the client's internal authentication state to `false`.
182    ///
183    /// # Returns
184    /// An empty `Ok(())` on successful logout.
185    ///
186    /// # Errors
187    /// Returns a `WattpadError` if the HTTP request fails.
188    pub async fn deauthenticate(&self) -> Result<(), WattpadError> {
189        let url = "https://www.wattpad.com/logout";
190
191        // 1. Send a GET request to the logout URL. The reqwest client's cookie store
192        //    will automatically handle the updated (cleared) session cookies from the response.
193        self.http.get(url).send().await?;
194
195        // 2. Set the local authentication flag to false.
196        self.is_authenticated.store(false, Ordering::SeqCst);
197        Ok(())
198    }
199
200    /// Checks if the client has been successfully authenticated.
201    ///
202    /// # Returns
203    /// `true` if `authenticate` has been called successfully, `false` otherwise.
204    pub fn is_authenticated(&self) -> bool {
205        self.is_authenticated.load(Ordering::SeqCst)
206    }
207}
208
209/// Provides a default implementation for `WattpadClient`.
210///
211/// This is equivalent to calling `WattpadClient::new()`.
212impl Default for WattpadClient {
213    fn default() -> Self {
214        Self::new()
215    }
216}
217
218/// A private helper function to process a `reqwest::Response`.
219///
220/// If the response status is successful, it deserializes the JSON body into type `T`.
221/// Otherwise, it attempts to parse a specific `ApiErrorResponse` format from the body.
222async fn handle_response<T: serde::de::DeserializeOwned>(
223    response: reqwest::Response,
224) -> Result<T, WattpadError> {
225    if response.status().is_success() {
226        let json = response.json::<T>().await?;
227        Ok(json)
228    } else {
229        let error_response = response.json::<ApiErrorResponse>().await?;
230        Err(error_response.into())
231    }
232}
233
234// =================================================================================================
235
236/// An internal builder for constructing and executing API requests.
237///
238/// This struct uses a fluent, chainable interface to build up an API call
239/// with its path, parameters, fields, and authentication requirements before sending it.
240pub(crate) struct WattpadRequestBuilder<'a> {
241    client: &'a reqwest::Client,
242    is_authenticated: &'a Arc<AtomicBool>,
243    method: reqwest::Method,
244    path: String,
245    params: Vec<(&'static str, String)>,
246    auth_required: bool,
247}
248
249impl<'a> WattpadRequestBuilder<'a> {
250    /// Creates a new request builder.
251    pub(crate) fn new(
252        client: &'a reqwest::Client,
253        is_authenticated: &'a Arc<AtomicBool>,
254        method: reqwest::Method,
255        path: &str,
256    ) -> Self {
257        Self {
258            client,
259            is_authenticated,
260            method,
261            path: path.to_string(),
262            params: Vec::new(),
263            auth_required: false,
264        }
265    }
266
267    /// A private helper to check for endpoint authentication before sending a request.
268    fn check_endpoint_auth(&self) -> Result<(), WattpadError> {
269        if self.auth_required && !self.is_authenticated.load(Ordering::SeqCst) {
270            return Err(WattpadError::AuthenticationRequired {
271                field: "Endpoint".to_string(),
272                context: format!("The endpoint at '{}' requires authentication.", self.path),
273            });
274        }
275        Ok(())
276    }
277
278    /// Marks the entire request as requiring authentication.
279    ///
280    /// If this is set, the request will fail with an error if the client is not authenticated.
281    pub(crate) fn requires_auth(mut self) -> Self {
282        self.auth_required = true;
283        self
284    }
285
286    /// Adds a query parameter to the request from an `Option`.
287    ///
288    /// If the value is `Some`, the parameter is added. If `None`, it's ignored.
289    pub(crate) fn maybe_param<T: ToString>(mut self, key: &'static str, value: Option<T>) -> Self {
290        if let Some(val) = value {
291            self.params.push((key, val.to_string()));
292        }
293        self
294    }
295
296    /// Adds the `fields` query parameter for field selection.
297    ///
298    /// This method handles using default fields if none are provided. It also performs a
299    /// crucial check to ensure that if any requested field requires authentication,
300    /// the client is currently authenticated.
301    ///
302    /// # Errors
303    /// Returns `WattpadError::AuthenticationRequired` if a field needs authentication
304    /// but the client is not logged in.
305    pub(crate) fn fields<T>(mut self, fields: Option<&[T]>) -> Result<Self, WattpadError>
306    where
307        T: ToString + DefaultableFields + AuthRequiredFields + PartialEq + Clone,
308    {
309        let fields_to_query = match fields {
310            Some(f) if !f.is_empty() => Cow::from(f),
311            _ => Cow::from(T::default_fields()),
312        };
313
314        if !self.is_authenticated.load(Ordering::SeqCst) {
315            if let Some(auth_field) = fields_to_query.iter().find(|f| f.auth_required()) {
316                return Err(WattpadError::AuthenticationRequired {
317                    field: auth_field.to_string(),
318                    context: format!(
319                        "The field '{}' requires authentication.",
320                        auth_field.to_string()
321                    ),
322                });
323            }
324        }
325
326        let fields_str = fields_to_query
327            .iter()
328            .map(|f| f.to_string())
329            .collect::<Vec<_>>()
330            .join(",");
331
332        self.params.push(("fields", fields_str));
333        Ok(self)
334    }
335
336    /// Adds a query parameter to the request.
337    pub(crate) fn param<T: ToString>(mut self, key: &'static str, value: Option<T>) -> Self {
338        if let Some(val) = value {
339            self.params.push((key, val.to_string()));
340        }
341        self
342    }
343
344    /// Executes the request and deserializes the JSON response into a specified type `T`.
345    pub(crate) async fn execute<T: serde::de::DeserializeOwned>(self) -> Result<T, WattpadError> {
346        self.check_endpoint_auth()?;
347
348        let url = format!("https://www.wattpad.com{}", self.path);
349        let response = self
350            .client
351            .request(self.method, &url)
352            .query(&self.params)
353            .send()
354            .await?;
355        handle_response(response).await
356    }
357
358    /// Executes the request and returns the raw response body as a `String`.
359    pub(crate) async fn execute_raw_text(self) -> Result<String, WattpadError> {
360        self.check_endpoint_auth()?;
361
362        let url = format!("https://www.wattpad.com{}", self.path);
363        let response = self
364            .client
365            .request(self.method, &url)
366            .query(&self.params)
367            .send()
368            .await?;
369
370        if response.status().is_success() {
371            Ok(response.text().await?)
372        } else {
373            let error_response = response.json::<ApiErrorResponse>().await?;
374            Err(error_response.into())
375        }
376    }
377
378    /// Executes the request and returns the raw response body as `Bytes`.
379    ///
380    /// This method is ideal for downloading files or other binary content.
381    pub(crate) async fn execute_bytes(self) -> Result<Bytes, WattpadError> {
382        self.check_endpoint_auth()?;
383
384        let url = format!("https://www.wattpad.com{}", self.path);
385        let response = self
386            .client
387            .request(self.method, &url)
388            .query(&self.params)
389            .send()
390            .await?;
391
392        if response.status().is_success() {
393            Ok(response.bytes().await?)
394        } else {
395            let error_response = response.json::<ApiErrorResponse>().await?;
396            Err(error_response.into())
397        }
398    }
399}