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.
240#[derive(Clone)] // Needed for pagination support.
241pub(crate) struct WattpadRequestBuilder<'a> {
242    client: &'a reqwest::Client,
243    is_authenticated: &'a Arc<AtomicBool>,
244    method: reqwest::Method,
245    path: String,
246    params: Vec<(&'static str, String)>,
247    auth_required: bool,
248}
249
250impl<'a> WattpadRequestBuilder<'a> {
251    /// Creates a new request builder.
252    pub(crate) fn new(
253        client: &'a reqwest::Client,
254        is_authenticated: &'a Arc<AtomicBool>,
255        method: reqwest::Method,
256        path: &str,
257    ) -> Self {
258        Self {
259            client,
260            is_authenticated,
261            method,
262            path: path.to_string(),
263            params: Vec::new(),
264            auth_required: false,
265        }
266    }
267
268    /// A private helper to check for endpoint authentication before sending a request.
269    fn check_endpoint_auth(&self) -> Result<(), WattpadError> {
270        if self.auth_required && !self.is_authenticated.load(Ordering::SeqCst) {
271            return Err(WattpadError::AuthenticationRequired {
272                field: "Endpoint".to_string(),
273                context: format!("The endpoint at '{}' requires authentication.", self.path),
274            });
275        }
276        Ok(())
277    }
278
279    /// Marks the entire request as requiring authentication.
280    ///
281    /// If this is set, the request will fail with an error if the client is not authenticated.
282    pub(crate) fn requires_auth(mut self) -> Self {
283        self.auth_required = true;
284        self
285    }
286
287    /// Adds a query parameter to the request from an `Option`.
288    ///
289    /// If the value is `Some`, the parameter is added. If `None`, it's ignored.
290    pub(crate) fn maybe_param<T: ToString>(mut self, key: &'static str, value: Option<T>) -> Self {
291        if let Some(val) = value {
292            self.params.push((key, val.to_string()));
293        }
294        self
295    }
296
297    /// Adds the `fields` query parameter for field selection.
298    ///
299    /// This method handles using default fields if none are provided. It also performs a
300    /// crucial check to ensure that if any requested field requires authentication,
301    /// the client is currently authenticated.
302    ///
303    /// # Errors
304    /// Returns `WattpadError::AuthenticationRequired` if a field needs authentication
305    /// but the client is not logged in.
306    pub(crate) fn fields<T>(mut self, fields: Option<&[T]>, wrap: Option<&str>) -> Result<Self, WattpadError>
307    where
308        T: ToString + DefaultableFields + AuthRequiredFields + PartialEq + Clone,
309    {
310        let fields_to_query = match fields {
311            Some(f) if !f.is_empty() => Cow::from(f),
312            _ => Cow::from(T::default_fields()),
313        };
314
315        if !self.is_authenticated.load(Ordering::SeqCst)
316            && let Some(auth_field) = fields_to_query.iter().find(|f| f.auth_required()) {
317                return Err(WattpadError::AuthenticationRequired {
318                    field: auth_field.to_string(),
319                    context: format!(
320                        "The field '{}' requires authentication.",
321                        auth_field.to_string()
322                    ),
323                });
324            }
325
326        let fields_str = {
327            let base = fields_to_query
328                .iter()
329                .map(|f| f.to_string())
330                .collect::<Vec<_>>()
331                .join(",");
332
333            wrap
334                .map(|w| format!("{w}({base})"))
335                .unwrap_or(base)
336        };
337
338        self.params.push(("fields", fields_str));
339        Ok(self)
340    }
341
342    /// Adds a query parameter to the request.
343    pub(crate) fn param<T: ToString>(mut self, key: &'static str, value: Option<T>) -> Self {
344        if let Some(val) = value {
345            self.params.push((key, val.to_string()));
346        }
347        self
348    }
349
350    /// Executes the request and deserializes the JSON response into a specified type `T`.
351    pub(crate) async fn execute<T: serde::de::DeserializeOwned>(self) -> Result<T, WattpadError> {
352        self.check_endpoint_auth()?;
353
354        let url = format!("https://www.wattpad.com{}", self.path);
355        let response = self
356            .client
357            .request(self.method, &url)
358            .query(&self.params)
359            .send()
360            .await?;
361        handle_response(response).await
362    }
363
364    /// Executes the request and returns the raw response body as a `String`.
365    pub(crate) async fn execute_raw_text(self) -> Result<String, WattpadError> {
366        self.check_endpoint_auth()?;
367
368        let url = format!("https://www.wattpad.com{}", self.path);
369        let response = self
370            .client
371            .request(self.method, &url)
372            .query(&self.params)
373            .send()
374            .await?;
375
376        if response.status().is_success() {
377            Ok(response.text().await?)
378        } else {
379            let error_response = response.json::<ApiErrorResponse>().await?;
380            Err(error_response.into())
381        }
382    }
383
384    /// Executes the request and returns the raw response body as `Bytes`.
385    ///
386    /// This method is ideal for downloading files or other binary content.
387    pub(crate) async fn execute_bytes(self) -> Result<Bytes, WattpadError> {
388        self.check_endpoint_auth()?;
389
390        let url = format!("https://www.wattpad.com{}", self.path);
391        let response = self
392            .client
393            .request(self.method, &url)
394            .query(&self.params)
395            .send()
396            .await?;
397
398        if response.status().is_success() {
399            Ok(response.bytes().await?)
400        } else {
401            let error_response = response.json::<ApiErrorResponse>().await?;
402            Err(error_response.into())
403        }
404    }
405}