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}