booru_rs/client/
mod.rs

1//! Client implementations for various booru sites.
2//!
3//! This module provides the [`Client`] trait and [`ClientBuilder`] for constructing
4//! and using booru API clients.
5//!
6//! # Available Clients
7//!
8//! - [`DanbooruClient`] — For [danbooru.donmai.us](https://danbooru.donmai.us) (2 tag limit)
9//! - [`GelbooruClient`] — For [gelbooru.com](https://gelbooru.com) (unlimited tags)
10//! - [`SafebooruClient`] — For [safebooru.org](https://safebooru.org) (unlimited tags, SFW only)
11//!
12//! # Example
13//!
14//! ```no_run
15//! use booru_rs::prelude::*;
16//!
17//! # async fn example() -> Result<()> {
18//! // Using the builder pattern
19//! let posts = GelbooruClient::builder()
20//!     .tags(["cat_ears", "blue_eyes"])?
21//!     .rating(GelbooruRating::General)
22//!     .sort(Sort::Score)
23//!     .limit(10)
24//!     .build()
25//!     .get()
26//!     .await?;
27//!
28//! // Get a specific post by ID
29//! let post = DanbooruClient::builder()
30//!     .build()
31//!     .get_by_id(12345)
32//!     .await?;
33//! # Ok(())
34//! # }
35//! ```
36//!
37//! # Custom HTTP Client
38//!
39//! By default, all clients share a connection-pooled HTTP client. You can provide
40//! your own client for custom configuration:
41//!
42//! ```no_run
43//! use booru_rs::prelude::*;
44//!
45//! # async fn example() -> Result<()> {
46//! let custom_client = reqwest::Client::builder()
47//!     .timeout(std::time::Duration::from_secs(60))
48//!     .build()
49//!     .unwrap();
50//!
51//! // Use ClientBuilder::with_client to create a builder with custom HTTP client
52//! let posts = ClientBuilder::<SafebooruClient>::with_client(custom_client)
53//!     .tag("nature")?
54//!     .build()
55//!     .get()
56//!     .await?;
57//! # Ok(())
58//! # }
59//! ```
60
61use std::sync::LazyLock;
62use std::time::Duration;
63
64use crate::error::{BooruError, Result};
65
66#[cfg(feature = "danbooru")]
67pub mod danbooru;
68#[cfg(feature = "gelbooru")]
69pub mod gelbooru;
70pub mod generic;
71#[cfg(feature = "rule34")]
72pub mod rule34;
73#[cfg(feature = "safebooru")]
74pub mod safebooru;
75
76/// Shared HTTP client with connection pooling and timeouts.
77///
78/// This client is lazily initialized and reused across all requests
79/// for better performance.
80static SHARED_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
81    reqwest::Client::builder()
82        .timeout(Duration::from_secs(30))
83        .connect_timeout(Duration::from_secs(10))
84        .pool_max_idle_per_host(10)
85        .pool_idle_timeout(Duration::from_secs(30))
86        .build()
87        .expect("Failed to create HTTP client")
88});
89
90/// Returns a reference to the shared HTTP client.
91#[inline]
92pub fn shared_client() -> &'static reqwest::Client {
93    &SHARED_CLIENT
94}
95
96/// Builder for constructing booru API clients.
97///
98/// This builder allows you to configure various options before
99/// creating a client to query a booru site.
100///
101/// # Example
102///
103/// ```no_run
104/// use booru_rs::danbooru::{DanbooruClient, DanbooruRating};
105/// use booru_rs::client::Client;
106///
107/// # async fn example() -> booru_rs::error::Result<()> {
108/// let client = DanbooruClient::builder()
109///     .tag("cat_ears")?
110///     .rating(DanbooruRating::General)
111///     .limit(10)
112///     .build();
113///
114/// let posts = client.get().await?;
115/// # Ok(())
116/// # }
117/// ```
118#[derive(Debug)]
119pub struct ClientBuilder<T: Client> {
120    pub(crate) client: reqwest::Client,
121    pub(crate) key: Option<String>,
122    pub(crate) user: Option<String>,
123    pub(crate) tags: Vec<String>,
124    pub(crate) limit: u32,
125    pub(crate) url: String,
126    pub(crate) page: u32,
127    _marker: std::marker::PhantomData<T>,
128}
129
130impl<T: Client> Clone for ClientBuilder<T> {
131    fn clone(&self) -> Self {
132        Self {
133            client: self.client.clone(),
134            key: self.key.clone(),
135            user: self.user.clone(),
136            tags: self.tags.clone(),
137            limit: self.limit,
138            url: self.url.clone(),
139            page: self.page,
140            _marker: std::marker::PhantomData,
141        }
142    }
143}
144
145/// Core trait for booru API clients.
146///
147/// This trait defines the interface that all booru clients must implement.
148/// It provides compile-time type safety for client-specific features like
149/// ratings and tag limits.
150///
151/// # Associated Types
152///
153/// - `Post`: The post type returned by this client
154/// - `Rating`: The rating type specific to this booru site
155///
156/// # Associated Constants
157///
158/// - `URL`: The base URL for the API
159/// - `SORT`: The prefix for sort/order tags
160/// - `MAX_TAGS`: Optional limit on the number of tags per query
161pub trait Client: From<ClientBuilder<Self>> + Sized + Send + Sync {
162    /// The post type returned by this client.
163    type Post: Send;
164
165    /// The rating type for this booru site.
166    type Rating: Into<String> + Send;
167
168    /// Base URL for the booru API.
169    const URL: &'static str;
170
171    /// Prefix used for sorting tags (e.g., "order:" or "sort:").
172    const SORT: &'static str;
173
174    /// Maximum number of tags allowed per query, or `None` for unlimited.
175    const MAX_TAGS: Option<usize>;
176
177    /// Creates a new builder for this client.
178    #[must_use]
179    fn builder() -> ClientBuilder<Self> {
180        ClientBuilder::new()
181    }
182
183    /// Retrieves a single post by its unique ID.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error if the request fails or if the post is not found.
188    fn get_by_id(&self, id: u32) -> impl std::future::Future<Output = Result<Self::Post>> + Send;
189
190    /// Retrieves posts matching the configured query.
191    ///
192    /// # Errors
193    ///
194    /// Returns an error if the request fails or if the response cannot be parsed.
195    fn get(&self) -> impl std::future::Future<Output = Result<Vec<Self::Post>>> + Send;
196}
197
198impl<T: Client> ClientBuilder<T> {
199    /// Creates a new builder with default settings.
200    ///
201    /// Uses the shared HTTP client for connection pooling.
202    #[must_use]
203    pub fn new() -> Self {
204        Self {
205            client: SHARED_CLIENT.clone(),
206            key: None,
207            user: None,
208            tags: Vec::new(),
209            limit: 100,
210            url: T::URL.to_string(),
211            page: 0,
212            _marker: std::marker::PhantomData,
213        }
214    }
215
216    /// Creates a new builder with a custom HTTP client.
217    ///
218    /// Use this when you need custom HTTP configuration (e.g., proxy, custom TLS).
219    #[must_use]
220    pub fn with_client(client: reqwest::Client) -> Self {
221        Self {
222            client,
223            key: None,
224            user: None,
225            tags: Vec::new(),
226            limit: 100,
227            url: T::URL.to_string(),
228            page: 0,
229            _marker: std::marker::PhantomData,
230        }
231    }
232
233    /// Sets a custom base URL for the API.
234    ///
235    /// This is primarily useful for testing with mock servers.
236    #[must_use]
237    pub fn with_custom_url(mut self, url: &str) -> Self {
238        self.url = url.to_string();
239        self
240    }
241
242    /// Sets the API key and username for authenticated requests.
243    ///
244    /// Some booru sites require or benefit from authentication.
245    #[must_use]
246    pub fn set_credentials(mut self, key: impl Into<String>, user: impl Into<String>) -> Self {
247        self.key = Some(key.into());
248        self.user = Some(user.into());
249        self
250    }
251
252    /// Adds a tag to the search query.
253    ///
254    /// # Errors
255    ///
256    /// Returns [`BooruError::TagLimitExceeded`] if adding this tag would exceed
257    /// the client's maximum tag limit.
258    ///
259    /// # Example
260    ///
261    /// ```no_run
262    /// use booru_rs::danbooru::DanbooruClient;
263    /// use booru_rs::client::Client;
264    ///
265    /// # fn example() -> booru_rs::error::Result<()> {
266    /// let client = DanbooruClient::builder()
267    ///     .tag("cat_ears")?
268    ///     .tag("blue_eyes")?
269    ///     .build();
270    /// # Ok(())
271    /// # }
272    /// ```
273    pub fn tag(mut self, tag: impl Into<String>) -> Result<Self> {
274        if let Some(max) = T::MAX_TAGS
275            && self.tags.len() >= max
276        {
277            return Err(BooruError::TagLimitExceeded {
278                client: std::any::type_name::<T>()
279                    .rsplit("::")
280                    .next()
281                    .unwrap_or("Unknown"),
282                max,
283                actual: self.tags.len() + 1,
284            });
285        }
286        self.tags.push(tag.into());
287        Ok(self)
288    }
289
290    /// Adds a rating filter to the search query.
291    ///
292    /// The rating type is specific to each booru site, ensuring
293    /// compile-time type safety.
294    ///
295    /// # Example
296    ///
297    /// ```no_run
298    /// use booru_rs::danbooru::{DanbooruClient, DanbooruRating};
299    /// use booru_rs::client::Client;
300    ///
301    /// let client = DanbooruClient::builder()
302    ///     .rating(DanbooruRating::General)
303    ///     .build();
304    /// ```
305    #[must_use]
306    pub fn rating(mut self, rating: T::Rating) -> Self {
307        self.tags.push(format!("rating:{}", rating.into()));
308        self
309    }
310
311    /// Sets the maximum number of posts to retrieve.
312    ///
313    /// Default is 100, which is also typically the maximum allowed by most APIs.
314    #[must_use]
315    pub fn limit(mut self, limit: u32) -> Self {
316        self.limit = limit;
317        self
318    }
319
320    /// Enables random ordering of results.
321    #[must_use]
322    pub fn random(mut self) -> Self {
323        self.tags.push(format!("{}random", T::SORT));
324        self
325    }
326
327    /// Adds a sort order to the query.
328    #[must_use]
329    pub fn sort(mut self, order: generic::Sort) -> Self {
330        self.tags.push(format!("{}{}", T::SORT, order));
331        self
332    }
333
334    /// Excludes posts with the specified tag.
335    ///
336    /// Multiple blacklist tags can be added by calling this method multiple times.
337    #[must_use]
338    pub fn blacklist_tag(mut self, tag: impl Into<String>) -> Self {
339        self.tags.push(format!("-{}", tag.into()));
340        self
341    }
342
343    /// Overrides the default API URL.
344    ///
345    /// Useful for testing or accessing mirror sites.
346    #[must_use]
347    pub fn default_url(mut self, url: impl Into<String>) -> Self {
348        self.url = url.into();
349        self
350    }
351
352    /// Sets the page number for pagination.
353    ///
354    /// Page numbering starts at 0.
355    #[must_use]
356    pub fn page(mut self, page: u32) -> Self {
357        self.page = page;
358        self
359    }
360
361    /// Adds multiple tags to the search query at once.
362    ///
363    /// # Errors
364    ///
365    /// Returns [`BooruError::TagLimitExceeded`] if adding these tags would exceed
366    /// the client's maximum tag limit.
367    ///
368    /// # Example
369    ///
370    /// ```no_run
371    /// use booru_rs::prelude::*;
372    ///
373    /// # fn example() -> Result<()> {
374    /// let client = GelbooruClient::builder()
375    ///     .tags(["cat_ears", "blue_eyes", "1girl"])?
376    ///     .build();
377    /// # Ok(())
378    /// # }
379    /// ```
380    pub fn tags<I, S>(mut self, tags: I) -> Result<Self>
381    where
382        I: IntoIterator<Item = S>,
383        S: Into<String>,
384    {
385        for tag in tags {
386            self = self.tag(tag)?;
387        }
388        Ok(self)
389    }
390
391    /// Excludes multiple tags from the search query at once.
392    ///
393    /// # Example
394    ///
395    /// ```no_run
396    /// use booru_rs::prelude::*;
397    ///
398    /// # fn example() -> Result<()> {
399    /// let client = GelbooruClient::builder()
400    ///     .tag("cat_ears")?
401    ///     .blacklist_tags(["ugly", "low_quality"])
402    ///     .build();
403    /// # Ok(())
404    /// # }
405    /// ```
406    #[must_use]
407    pub fn blacklist_tags<I, S>(mut self, tags: I) -> Self
408    where
409        I: IntoIterator<Item = S>,
410        S: Into<String>,
411    {
412        for tag in tags {
413            self = self.blacklist_tag(tag);
414        }
415        self
416    }
417
418    /// Returns the current number of tags in the query.
419    #[must_use]
420    pub fn tag_count(&self) -> usize {
421        self.tags.len()
422    }
423
424    /// Returns `true` if the builder has any tags configured.
425    #[must_use]
426    pub fn has_tags(&self) -> bool {
427        !self.tags.is_empty()
428    }
429
430    /// Builds the client with the configured options.
431    #[must_use]
432    pub fn build(self) -> T {
433        T::from(self)
434    }
435}
436
437impl<T: Client> Default for ClientBuilder<T> {
438    fn default() -> Self {
439        Self::new()
440    }
441}
442
443// Re-exports for convenience
444#[cfg(feature = "danbooru")]
445pub use danbooru::DanbooruClient;
446#[cfg(feature = "gelbooru")]
447pub use gelbooru::GelbooruClient;
448#[cfg(feature = "rule34")]
449pub use rule34::Rule34Client;
450#[cfg(feature = "safebooru")]
451pub use safebooru::SafebooruClient;