wp-mini 0.2.0-alpha.3

Minimal async API Wrapper for WP | Only Reader/Public API | Extremely minimal.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//! The main module for the Wattpad API client.
//!
//! It contains the primary `WattpadClient`, which serves as the entry point for all API
//! interactions. It also includes the internal `WattpadRequestBuilder` for constructing
//! and executing API calls, and helper functions for handling responses.

use crate::error::{ApiErrorResponse, WattpadError};
use crate::endpoints::story::StoryClient;
use crate::endpoints::user::UserClient;
use crate::field::{AuthRequiredFields, DefaultableFields};
use bytes::Bytes;
use reqwest::Client as ReqwestClient;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, USER_AGENT};
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

// =================================================================================================
// WattpadClientBuilder
// =================================================================================================

/// A builder for creating a `WattpadClient` with custom configuration.
#[derive(Default)]
pub struct WattpadClientBuilder {
    client: Option<ReqwestClient>,
    user_agent: Option<String>,
    headers: Option<HeaderMap>,
}

impl WattpadClientBuilder {
    /// Provide a pre-configured `reqwest::Client`.
    /// If this is used, any other configurations like `.user_agent()` or `.header()` will be ignored,
    /// as the provided client is assumed to be fully configured.
    pub fn reqwest_client(mut self, client: ReqwestClient) -> Self {
        self.client = Some(client);
        self
    }

    /// Set a custom User-Agent string for all requests.
    pub fn user_agent(mut self, user_agent: &str) -> Self {
        self.user_agent = Some(user_agent.to_string());
        self
    }

    /// Add a single custom header to be sent with all requests.
    pub fn header(mut self, key: HeaderName, value: HeaderValue) -> Self {
        self.headers.get_or_insert_with(HeaderMap::new).insert(key, value);
        self
    }

    /// Builds the `WattpadClient`.
    ///
    /// If a `reqwest::Client` was not provided via the builder, a new default one will be created.
    pub fn build(self) -> WattpadClient {
        let http_client = match self.client {
            // If a client was provided, use it directly.
            Some(client) => client,
            // Otherwise, build a new client using the builder's settings.
            None => {
                let mut headers = self.headers.unwrap_or_default();

                // Set the User-Agent, preferring the custom one, otherwise use the default.
                let ua_string = self.user_agent.unwrap_or_else(||
                    "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()

                );

                // Insert the user-agent header, it will override any existing one in the map.
                headers.insert(USER_AGENT, HeaderValue::from_str(&ua_string).expect("Invalid User-Agent string"));

                let mut client_builder = ReqwestClient::builder()
                    .default_headers(headers);

                #[cfg(not(target_arch = "wasm32"))]
                {
                    client_builder = client_builder.cookie_store(true);
                }
 
                client_builder.build()
                    .expect("Failed to build reqwest client")
            }
        };

        // The rest of the logic remains the same
        let auth_flag = Arc::new(AtomicBool::new(false));
        WattpadClient {
            user: UserClient {
                http: http_client.clone(),
                is_authenticated: auth_flag.clone(),
            },
            story: StoryClient {
                http: http_client.clone(),
                is_authenticated: auth_flag.clone(),
            },
            http: http_client,
            is_authenticated: auth_flag,
        }
    }
}

/// The main asynchronous client for interacting with the Wattpad API.
///
/// This client holds the HTTP connection, manages authentication state, and provides
/// access to categorized sub-clients for different parts of the API.
pub struct WattpadClient {
    /// The underlying `reqwest` client used for all HTTP requests.
    http: reqwest::Client,
    /// An atomically-managed boolean flag to track authentication status.
    is_authenticated: Arc<AtomicBool>,
    /// Provides access to user-related API endpoints.
    pub user: UserClient,
    /// Provides access to story and part-related API endpoints.
    pub story: StoryClient,
}

impl WattpadClient {
    /// Creates a new `WattpadClient` with default settings.
    ///
    /// This is now a convenience method that uses the builder.
    pub fn new() -> Self {
        WattpadClientBuilder::default().build()
    }

    /// Creates a new builder for configuring a `WattpadClient`.
    ///
    /// This is the new entry point for custom client creation.
    pub fn builder() -> WattpadClientBuilder {
        WattpadClientBuilder::default()
    }

    /// Authenticates the client using a username and password.
    ///
    /// On a successful login, the API returns session cookies which are automatically
    /// stored in the client's cookie store for use in subsequent requests.
    ///
    /// # Arguments
    /// * `username` - The Wattpad username.
    /// * `password` - The Wattpad password.
    ///
    /// # Returns
    /// An empty `Ok(())` on successful authentication.
    ///
    /// # Errors
    /// Returns `WattpadError::AuthenticationFailed` if login is unsuccessful.
    pub async fn authenticate(&self, username: &str, password: &str) -> Result<(), WattpadError> {
        let url = "https://www.wattpad.com/auth/login?&_data=routes%2Fauth.login";

        let mut payload = HashMap::new();
        payload.insert("username", username);
        payload.insert("password", password);

        let response = self.http.post(url).form(&payload).send().await?;

        // --- NATIVE-SPECIFIC LOGIC ---
        // For native builds, we verify that cookies were actually returned.
        #[cfg(not(target_arch = "wasm32"))]
        {
            if response.cookies().next().is_none() {
                self.is_authenticated.store(false, Ordering::SeqCst);
                return Err(WattpadError::AuthenticationFailed);
            }
        }

        // --- WASM-SPECIFIC LOGIC ---
        // For WASM, the browser handles cookies. We just check for a success status.
        #[cfg(target_arch = "wasm32")]
        {
            if !response.status().is_success() {
                self.is_authenticated.store(false, Ordering::SeqCst);
                return Err(WattpadError::AuthenticationFailed);
            }
        }

        self.is_authenticated.store(true, Ordering::SeqCst);
        Ok(())
    }

    /// Deauthenticates the client by logging out from Wattpad.
    ///
    /// This method sends a request to the logout endpoint, which invalidates the session
    /// cookies. It then sets the client's internal authentication state to `false`.
    ///
    /// # Returns
    /// An empty `Ok(())` on successful logout.
    ///
    /// # Errors
    /// Returns a `WattpadError` if the HTTP request fails.
    pub async fn deauthenticate(&self) -> Result<(), WattpadError> {
        let url = "https://www.wattpad.com/logout";

        // 1. Send a GET request to the logout URL. The reqwest client's cookie store
        //    will automatically handle the updated (cleared) session cookies from the response.
        self.http.get(url).send().await?;

        // 2. Set the local authentication flag to false.
        self.is_authenticated.store(false, Ordering::SeqCst);
        Ok(())
    }

    /// Checks if the client has been successfully authenticated.
    ///
    /// # Returns
    /// `true` if `authenticate` has been called successfully, `false` otherwise.
    pub fn is_authenticated(&self) -> bool {
        self.is_authenticated.load(Ordering::SeqCst)
    }
}

/// Provides a default implementation for `WattpadClient`.
///
/// This is equivalent to calling `WattpadClient::new()`.
impl Default for WattpadClient {
    fn default() -> Self {
        Self::new()
    }
}

/// A private helper function to process a `reqwest::Response`.
///
/// If the response status is successful, it deserializes the JSON body into type `T`.
/// Otherwise, it attempts to parse a specific `ApiErrorResponse` format from the body.
async fn handle_response<T: serde::de::DeserializeOwned>(
    response: reqwest::Response,
) -> Result<T, WattpadError> {
    if response.status().is_success() {
        let json = response.json::<T>().await?;
        Ok(json)
    } else {
        let error_response = response.json::<ApiErrorResponse>().await?;
        Err(error_response.into())
    }
}

// =================================================================================================

/// An internal builder for constructing and executing API requests.
///
/// This struct uses a fluent, chainable interface to build up an API call
/// with its path, parameters, fields, and authentication requirements before sending it.
#[derive(Clone)] // Needed for pagination support.
pub(crate) struct WattpadRequestBuilder<'a> {
    client: &'a reqwest::Client,
    is_authenticated: &'a Arc<AtomicBool>,
    method: reqwest::Method,
    path: String,
    params: Vec<(&'static str, String)>,
    auth_required: bool,
}

impl<'a> WattpadRequestBuilder<'a> {
    /// Creates a new request builder.
    pub(crate) fn new(
        client: &'a reqwest::Client,
        is_authenticated: &'a Arc<AtomicBool>,
        method: reqwest::Method,
        path: &str,
    ) -> Self {
        Self {
            client,
            is_authenticated,
            method,
            path: path.to_string(),
            params: Vec::new(),
            auth_required: false,
        }
    }

    /// A private helper to check for endpoint authentication before sending a request.
    fn check_endpoint_auth(&self) -> Result<(), WattpadError> {
        if self.auth_required && !self.is_authenticated.load(Ordering::SeqCst) {
            return Err(WattpadError::AuthenticationRequired {
                field: "Endpoint".to_string(),
                context: format!("The endpoint at '{}' requires authentication.", self.path),
            });
        }
        Ok(())
    }

    /// Marks the entire request as requiring authentication.
    ///
    /// If this is set, the request will fail with an error if the client is not authenticated.
    pub(crate) fn requires_auth(mut self) -> Self {
        self.auth_required = true;
        self
    }

    /// Adds a query parameter to the request from an `Option`.
    ///
    /// If the value is `Some`, the parameter is added. If `None`, it's ignored.
    pub(crate) fn maybe_param<T: ToString>(mut self, key: &'static str, value: Option<T>) -> Self {
        if let Some(val) = value {
            self.params.push((key, val.to_string()));
        }
        self
    }

    /// Adds the `fields` query parameter for field selection.
    ///
    /// This method handles using default fields if none are provided. It also performs a
    /// crucial check to ensure that if any requested field requires authentication,
    /// the client is currently authenticated.
    ///
    /// # Errors
    /// Returns `WattpadError::AuthenticationRequired` if a field needs authentication
    /// but the client is not logged in.
    pub(crate) fn fields<T>(mut self, fields: Option<&[T]>, wrap: Option<&str>) -> Result<Self, WattpadError>
    where
        T: ToString + DefaultableFields + AuthRequiredFields + PartialEq + Clone,
    {
        let fields_to_query = match fields {
            Some(f) if !f.is_empty() => Cow::from(f),
            _ => Cow::from(T::default_fields()),
        };

        if !self.is_authenticated.load(Ordering::SeqCst)
            && let Some(auth_field) = fields_to_query.iter().find(|f| f.auth_required()) {
                return Err(WattpadError::AuthenticationRequired {
                    field: auth_field.to_string(),
                    context: format!(
                        "The field '{}' requires authentication.",
                        auth_field.to_string()
                    ),
                });
            }

        let fields_str = {
            let base = fields_to_query
                .iter()
                .map(|f| f.to_string())
                .collect::<Vec<_>>()
                .join(",");

            wrap
                .map(|w| format!("{w}({base})"))
                .unwrap_or(base)
        };

        self.params.push(("fields", fields_str));
        Ok(self)
    }

    /// Adds a query parameter to the request.
    pub(crate) fn param<T: ToString>(mut self, key: &'static str, value: Option<T>) -> Self {
        if let Some(val) = value {
            self.params.push((key, val.to_string()));
        }
        self
    }

    /// Executes the request and deserializes the JSON response into a specified type `T`.
    pub(crate) async fn execute<T: serde::de::DeserializeOwned>(self) -> Result<T, WattpadError> {
        self.check_endpoint_auth()?;

        let url = format!("https://www.wattpad.com{}", self.path);
        let response = self
            .client
            .request(self.method, &url)
            .query(&self.params)
            .send()
            .await?;
        handle_response(response).await
    }

    /// Executes the request and returns the raw response body as a `String`.
    pub(crate) async fn execute_raw_text(self) -> Result<String, WattpadError> {
        self.check_endpoint_auth()?;

        let url = format!("https://www.wattpad.com{}", self.path);
        let response = self
            .client
            .request(self.method, &url)
            .query(&self.params)
            .send()
            .await?;

        if response.status().is_success() {
            Ok(response.text().await?)
        } else {
            let error_response = response.json::<ApiErrorResponse>().await?;
            Err(error_response.into())
        }
    }

    /// Executes the request and returns the raw response body as `Bytes`.
    ///
    /// This method is ideal for downloading files or other binary content.
    pub(crate) async fn execute_bytes(self) -> Result<Bytes, WattpadError> {
        self.check_endpoint_auth()?;

        let url = format!("https://www.wattpad.com{}", self.path);
        let response = self
            .client
            .request(self.method, &url)
            .query(&self.params)
            .send()
            .await?;

        if response.status().is_success() {
            Ok(response.bytes().await?)
        } else {
            let error_response = response.json::<ApiErrorResponse>().await?;
            Err(error_response.into())
        }
    }
}