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;