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}