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