Skip to main content

spoo_me/
client.rs

1/// A client for the URL shortener API.
2use crate::{
3    errors::{ApiError, UrlShortenerError, ValidationError},
4    requests::{
5        EmojiRequest, EmojiResponse, ExportRequest, ExportResponse, ShortenRequest,
6        ShortenResponse, StatsRequest, StatsResponse,
7    },
8    utils::{is_valid_alias, is_valid_max_clicks, is_valid_password, is_valid_url},
9};
10
11/// A client for the URL shortener API.
12///
13/// This client can be used in both async and blocking modes, depending on the feature flags.
14///
15/// # Example usage:
16/// ```rust
17/// use spoo_me::client::UrlShortenerClient;
18/// use spoo_me::requests::ShortenRequest;
19/// use spoo_me::errors::UrlShortenerError;
20///
21/// #[tokio::main]
22/// async fn main() -> Result<(), UrlShortenerError> {
23///     let client = UrlShortenerClient::new();
24///     let request = ShortenRequest::new("https://example.com/long/url")
25///         .password("Example@123")
26///         .max_clicks(100)
27///         .block_bots(true);
28///
29///     let response = client.shorten(request).await?;
30///     println!("Shortened URL: {}", response.short_url);
31///     Ok(())
32/// }
33pub struct UrlShortenerClient {
34    base_url: String,
35    #[cfg(not(feature = "blocking"))]
36    client: reqwest::Client,
37    #[cfg(feature = "blocking")]
38    client: reqwest::blocking::Client,
39}
40
41impl UrlShortenerClient {
42    /// Create a new client
43    pub fn new() -> Self {
44        UrlShortenerClient {
45            base_url: "https://spoo.me".to_string(),
46            #[cfg(not(feature = "blocking"))]
47            client: reqwest::Client::new(),
48            #[cfg(feature = "blocking")]
49            client: reqwest::blocking::Client::new(),
50        }
51    }
52
53    /// Create a new client with a custom base URL
54    ///
55    /// Requires the `custom_url` feature to be enabled.
56    #[cfg(feature = "custom_url")]
57    pub fn new_with_base_url(url: &str) -> Self {
58        UrlShortenerClient {
59            base_url: url.to_string(),
60            #[cfg(not(feature = "blocking"))]
61            client: reqwest::Client::new(),
62            #[cfg(feature = "blocking")]
63            client: reqwest::blocking::Client::new(),
64        }
65    }
66
67    /// Set a custom base URL for the client.
68    ///
69    /// Requires the `custom_url` feature to be enabled.
70    #[cfg(feature = "custom_url")]
71    pub fn set_base_url(&mut self, url: &str) {
72        self.base_url = url.to_string();
73    }
74
75    /// Shorten a URL (async mode).
76    #[cfg(not(feature = "blocking"))]
77    pub async fn shorten(&self, req: ShortenRequest) -> Result<ShortenResponse, UrlShortenerError> {
78        if let Some(ref pw) = req.password {
79            if !is_valid_password(pw) {
80                return Err(UrlShortenerError::Validation(
81                    ValidationError::InvalidPasswordFormat(pw.clone()),
82                ));
83            }
84        }
85
86        #[cfg(feature = "custom_url")]
87        if !is_valid_url(&req.url, &self.base_url) {
88            return Err(UrlShortenerError::Validation(
89                ValidationError::InvalidUrlFormat(req.url.clone()),
90            ));
91        }
92        #[cfg(not(feature = "custom_url"))]
93        if !is_valid_url(&req.url) {
94            return Err(UrlShortenerError::Validation(
95                ValidationError::InvalidUrlFormat(req.url.clone()),
96            ));
97        }
98
99        if let Some(ref alias) = req.alias {
100            if !is_valid_alias(alias) {
101                return Err(UrlShortenerError::Validation(
102                    ValidationError::InvalidAliasFormat(alias.clone()),
103                ));
104            }
105        }
106
107        if let Some(max_clicks) = req.max_clicks {
108            if !is_valid_max_clicks(max_clicks) {
109                return Err(UrlShortenerError::Validation(
110                    ValidationError::InvalidMaxClicks(max_clicks),
111                ));
112            }
113        }
114
115        let resp = self
116            .client
117            .post(format!("{}/", self.base_url))
118            .header("Accept", "application/json")
119            .form(&req)
120            .send()
121            .await
122            .map_err(UrlShortenerError::Http)?;
123
124        let status = resp.status();
125        let text = resp.text().await.map_err(UrlShortenerError::Http)?;
126        if !status.is_success() {
127            if status.as_u16() == 429 {
128                return Err(UrlShortenerError::Api(ApiError::RateLimitExceeded));
129            }
130
131            if let Ok(err_json) = serde_json::from_str::<serde_json::Value>(&text) {
132                if let Some(err) = err_json.get("error").and_then(|e| e.as_str()) {
133                    return Err(UrlShortenerError::Api(match err {
134                        "UrlError" => ApiError::UrlError,
135                        "AliasError" => ApiError::AliasError,
136                        "PasswordError" => ApiError::PasswordError,
137                        "MaxClicksError" => ApiError::MaxClicksError,
138                        "EmojiError" => ApiError::EmojiError,
139                        _ => ApiError::UrlError,
140                    }));
141                }
142            }
143            return Err(UrlShortenerError::Other(text));
144        }
145
146        let result =
147            serde_json::from_str::<ShortenResponse>(&text).map_err(UrlShortenerError::Json)?;
148
149        Ok(result)
150    }
151
152    /// Shorten a URL (blocking mode).
153    #[cfg(feature = "blocking")]
154    pub fn shorten_blocking(
155        &self,
156        req: ShortenRequest,
157    ) -> Result<ShortenResponse, UrlShortenerError> {
158        if let Some(ref pw) = req.password {
159            if !is_valid_password(pw) {
160                return Err(UrlShortenerError::Validation(
161                    ValidationError::InvalidPasswordFormat(pw.clone()),
162                ));
163            }
164        }
165
166        #[cfg(feature = "custom_url")]
167        if !is_valid_url(&req.url, &self.base_url) {
168            return Err(UrlShortenerError::Validation(
169                ValidationError::InvalidUrlFormat(req.url.clone()),
170            ));
171        }
172        #[cfg(not(feature = "custom_url"))]
173        if !is_valid_url(&req.url) {
174            return Err(UrlShortenerError::Validation(
175                ValidationError::InvalidUrlFormat(req.url.clone()),
176            ));
177        }
178
179        if let Some(ref alias) = req.alias {
180            if !is_valid_alias(alias) {
181                return Err(UrlShortenerError::Validation(
182                    ValidationError::InvalidAliasFormat(alias.clone()),
183                ));
184            }
185        }
186
187        if let Some(max_clicks) = req.max_clicks {
188            if !is_valid_max_clicks(max_clicks) {
189                return Err(UrlShortenerError::Validation(
190                    ValidationError::InvalidMaxClicks(max_clicks),
191                ));
192            }
193        }
194
195        let resp = self
196            .client
197            .post(format!("{}/", self.base_url))
198            .header("Accept", "application/json")
199            .form(&req)
200            .send()
201            .map_err(UrlShortenerError::Http)?;
202
203        let status = resp.status();
204        let text = resp.text().map_err(UrlShortenerError::Http)?;
205        if !status.is_success() {
206            if status.as_u16() == 429 {
207                return Err(UrlShortenerError::Api(ApiError::RateLimitExceeded));
208            }
209
210            if let Ok(err_json) = serde_json::from_str::<serde_json::Value>(&text) {
211                if let Some(err) = err_json.get("error").and_then(|e| e.as_str()) {
212                    return Err(UrlShortenerError::Api(match err {
213                        "UrlError" => ApiError::UrlError,
214                        "AliasError" => ApiError::AliasError,
215                        "PasswordError" => ApiError::PasswordError,
216                        "MaxClicksError" => ApiError::MaxClicksError,
217                        "EmojiError" => ApiError::EmojiError,
218                        _ => ApiError::UrlError,
219                    }));
220                }
221            }
222            return Err(UrlShortenerError::Other(text));
223        }
224
225        let result =
226            serde_json::from_str::<ShortenResponse>(&text).map_err(UrlShortenerError::Json)?;
227
228        Ok(result)
229    }
230
231    /// Create an emoji URL (async mode).
232    #[cfg(not(feature = "blocking"))]
233    pub async fn emoji(&self, req: EmojiRequest) -> Result<EmojiResponse, UrlShortenerError> {
234        if let Some(ref pw) = req.password {
235            if !is_valid_password(pw) {
236                return Err(UrlShortenerError::Validation(
237                    ValidationError::InvalidPasswordFormat(pw.clone()),
238                ));
239            }
240        }
241
242        #[cfg(feature = "custom_url")]
243        if !is_valid_url(&req.url, &self.base_url) {
244            return Err(UrlShortenerError::Validation(
245                ValidationError::InvalidUrlFormat(req.url.clone()),
246            ));
247        }
248        #[cfg(not(feature = "custom_url"))]
249        if !is_valid_url(&req.url) {
250            return Err(UrlShortenerError::Validation(
251                ValidationError::InvalidUrlFormat(req.url.clone()),
252            ));
253        }
254
255        if let Some(max_clicks) = req.max_clicks {
256            if !is_valid_max_clicks(max_clicks) {
257                return Err(UrlShortenerError::Validation(
258                    ValidationError::InvalidMaxClicks(max_clicks),
259                ));
260            }
261        }
262
263        let resp = self
264            .client
265            .post(format!("{}/emoji", self.base_url))
266            .header("Accept", "application/json")
267            .form(&req)
268            .send()
269            .await
270            .map_err(UrlShortenerError::Http)?;
271
272        let status = resp.status();
273        let text = resp.text().await.map_err(UrlShortenerError::Http)?;
274        if !status.is_success() {
275            if status.as_u16() == 429 {
276                return Err(UrlShortenerError::Api(ApiError::RateLimitExceeded));
277            }
278
279            if let Ok(err_json) = serde_json::from_str::<serde_json::Value>(&text) {
280                if let Some(err) = err_json.get("error").and_then(|e| e.as_str()) {
281                    return Err(UrlShortenerError::Api(match err {
282                        "UrlError" => ApiError::UrlError,
283                        "AliasError" => ApiError::AliasError,
284                        "PasswordError" => ApiError::PasswordError,
285                        "MaxClicksError" => ApiError::MaxClicksError,
286                        "EmojiError" => ApiError::EmojiError,
287                        err => ApiError::Other(err.to_string()),
288                    }));
289                }
290            }
291            return Err(UrlShortenerError::Other(text));
292        }
293
294        let result =
295            serde_json::from_str::<EmojiResponse>(&text).map_err(UrlShortenerError::Json)?;
296
297        Ok(result)
298    }
299
300    /// Create an emoji URL (blocking mode).
301    #[cfg(feature = "blocking")]
302    pub fn emoji_blocking(&self, req: EmojiRequest) -> Result<EmojiResponse, UrlShortenerError> {
303        if let Some(ref pw) = req.password {
304            if !is_valid_password(pw) {
305                return Err(UrlShortenerError::Validation(
306                    ValidationError::InvalidPasswordFormat(pw.clone()),
307                ));
308            }
309        }
310
311        #[cfg(feature = "custom_url")]
312        if !is_valid_url(&req.url, &self.base_url) {
313            return Err(UrlShortenerError::Validation(
314                ValidationError::InvalidUrlFormat(req.url.clone()),
315            ));
316        }
317        #[cfg(not(feature = "custom_url"))]
318        if !is_valid_url(&req.url) {
319            return Err(UrlShortenerError::Validation(
320                ValidationError::InvalidUrlFormat(req.url.clone()),
321            ));
322        }
323
324        if let Some(max_clicks) = req.max_clicks {
325            if !is_valid_max_clicks(max_clicks) {
326                return Err(UrlShortenerError::Validation(
327                    ValidationError::InvalidMaxClicks(max_clicks),
328                ));
329            }
330        }
331
332        let resp = self
333            .client
334            .post(format!("{}/emoji", self.base_url))
335            .header("Accept", "application/json")
336            .form(&req)
337            .send()
338            .map_err(UrlShortenerError::Http)?;
339
340        let status = resp.status();
341        let text = resp.text().map_err(UrlShortenerError::Http)?;
342        if !status.is_success() {
343            if status.as_u16() == 429 {
344                return Err(UrlShortenerError::Api(ApiError::RateLimitExceeded));
345            }
346
347            if let Ok(err_json) = serde_json::from_str::<serde_json::Value>(&text) {
348                if let Some(err) = err_json.get("error").and_then(|e| e.as_str()) {
349                    return Err(UrlShortenerError::Api(match err {
350                        "UrlError" => ApiError::UrlError,
351                        "AliasError" => ApiError::AliasError,
352                        "PasswordError" => ApiError::PasswordError,
353                        "MaxClicksError" => ApiError::MaxClicksError,
354                        "EmojiError" => ApiError::EmojiError,
355                        _ => ApiError::UrlError,
356                    }));
357                }
358            }
359            return Err(UrlShortenerError::Other(text));
360        }
361
362        let result =
363            serde_json::from_str::<EmojiResponse>(&text).map_err(UrlShortenerError::Json)?;
364
365        Ok(result)
366    }
367
368    /// Get statistics for a shortened URL (async mode).
369    #[cfg(not(feature = "blocking"))]
370    pub async fn stats(&self, req: StatsRequest) -> Result<StatsResponse, UrlShortenerError> {
371        if req.short_code.is_empty() {
372            return Err(UrlShortenerError::Validation(
373                ValidationError::InvalidPasswordFormat("Short code cannot be empty".to_string()),
374            ));
375        }
376
377        if let Some(ref pw) = req.password {
378            if !is_valid_password(pw) {
379                return Err(UrlShortenerError::Validation(
380                    ValidationError::InvalidPasswordFormat(pw.clone()),
381                ));
382            }
383        }
384
385        if !is_valid_alias(&req.short_code) {
386            return Err(UrlShortenerError::Validation(
387                ValidationError::InvalidAliasFormat(req.short_code.clone()),
388            ));
389        }
390
391        let resp = self
392            .client
393            .post(format!("{}/stats/{}", self.base_url, req.short_code))
394            .header("Accept", "application/json")
395            .form(&req)
396            .send()
397            .await
398            .map_err(UrlShortenerError::Http)?;
399
400        let status = resp.status();
401        let text = resp.text().await.map_err(UrlShortenerError::Http)?;
402        if !status.is_success() {
403            if status.as_u16() == 429 {
404                return Err(UrlShortenerError::Api(ApiError::RateLimitExceeded));
405            }
406
407            if let Ok(err_json) = serde_json::from_str::<serde_json::Value>(&text) {
408                if let Some(err) = err_json.get("error").and_then(|e| e.as_str()) {
409                    return Err(UrlShortenerError::Api(match err {
410                        "UrlError" => ApiError::UrlError,
411                        "AliasError" => ApiError::AliasError,
412                        "PasswordError" => ApiError::PasswordError,
413                        "MaxClicksError" => ApiError::MaxClicksError,
414                        "EmojiError" => ApiError::EmojiError,
415                        _ => ApiError::UrlError,
416                    }));
417                }
418            }
419            return Err(UrlShortenerError::Other(text));
420        }
421
422        let result =
423            serde_json::from_str::<StatsResponse>(&text).map_err(UrlShortenerError::Json)?;
424
425        Ok(result)
426    }
427
428    /// Get statistics for a shortened URL (blocking mode).
429    #[cfg(feature = "blocking")]
430    pub fn stats_blocking(&self, req: StatsRequest) -> Result<StatsResponse, UrlShortenerError> {
431        if req.short_code.is_empty() {
432            return Err(UrlShortenerError::Validation(
433                ValidationError::InvalidPasswordFormat("Short code cannot be empty".to_string()),
434            ));
435        }
436
437        if let Some(ref pw) = req.password {
438            if !is_valid_password(pw) {
439                return Err(UrlShortenerError::Validation(
440                    ValidationError::InvalidPasswordFormat(pw.clone()),
441                ));
442            }
443        }
444
445        if !is_valid_alias(&req.short_code) {
446            return Err(UrlShortenerError::Validation(
447                ValidationError::InvalidAliasFormat(req.short_code.clone()),
448            ));
449        }
450
451        let resp = self
452            .client
453            .post(format!("{}/stats/{}", self.base_url, req.short_code))
454            .header("Accept", "application/json")
455            .form(&req)
456            .send()
457            .map_err(UrlShortenerError::Http)?;
458
459        let status = resp.status();
460        let text = resp.text().map_err(UrlShortenerError::Http)?;
461        if !status.is_success() {
462            if status.as_u16() == 429 {
463                return Err(UrlShortenerError::Api(ApiError::RateLimitExceeded));
464            }
465
466            if let Ok(err_json) = serde_json::from_str::<serde_json::Value>(&text) {
467                if let Some(err) = err_json.get("error").and_then(|e| e.as_str()) {
468                    return Err(UrlShortenerError::Api(match err {
469                        "UrlError" => ApiError::UrlError,
470                        "AliasError" => ApiError::AliasError,
471                        "PasswordError" => ApiError::PasswordError,
472                        "MaxClicksError" => ApiError::MaxClicksError,
473                        "EmojiError" => ApiError::EmojiError,
474                        _ => ApiError::UrlError,
475                    }));
476                }
477            }
478            return Err(UrlShortenerError::Other(text));
479        }
480
481        let result =
482            serde_json::from_str::<StatsResponse>(&text).map_err(UrlShortenerError::Json)?;
483
484        Ok(result)
485    }
486
487    /// Export data for a shortened URL (async mode).
488    #[cfg(not(feature = "blocking"))]
489    pub async fn export(&self, req: ExportRequest) -> Result<ExportResponse, UrlShortenerError> {
490        if req.short_code.is_empty() {
491            return Err(UrlShortenerError::Validation(
492                ValidationError::InvalidAliasFormat(req.short_code),
493            ));
494        }
495
496        if !is_valid_alias(&req.short_code) {
497            return Err(UrlShortenerError::Validation(
498                ValidationError::InvalidAliasFormat(req.short_code.clone()),
499            ));
500        }
501
502        let resp = self
503            .client
504            .post(format!(
505                "{}/export/{}/{}",
506                self.base_url, req.short_code, req.export_format
507            ))
508            .form(&req)
509            .send()
510            .await
511            .map_err(UrlShortenerError::Http)?;
512
513        let status = resp.status();
514        if !status.is_success() {
515            if status.as_u16() == 429 {
516                return Err(UrlShortenerError::Api(ApiError::RateLimitExceeded));
517            }
518
519            let text = resp.text().await.map_err(UrlShortenerError::Http)?;
520            if let Ok(err_json) = serde_json::from_str::<serde_json::Value>(&text) {
521                if let Some(err) = err_json.get("error").and_then(|e| e.as_str()) {
522                    return Err(UrlShortenerError::Api(match err {
523                        "UrlError" => ApiError::UrlError,
524                        "AliasError" => ApiError::AliasError,
525                        "PasswordError" => ApiError::PasswordError,
526                        "MaxClicksError" => ApiError::MaxClicksError,
527                        "EmojiError" => ApiError::EmojiError,
528                        _ => ApiError::Other(err.to_string()),
529                    }));
530                }
531            }
532            return Err(UrlShortenerError::Other(text));
533        }
534
535        let data = resp.bytes().await.map_err(UrlShortenerError::Http)?;
536        let result = ExportResponse {
537            data: data.to_vec(),
538        };
539
540        Ok(result)
541    }
542
543    /// Export data for a shortened URL (blocking mode).
544    #[cfg(feature = "blocking")]
545    pub fn export_blocking(&self, req: ExportRequest) -> Result<ExportResponse, UrlShortenerError> {
546        if req.short_code.is_empty() {
547            return Err(UrlShortenerError::Validation(
548                ValidationError::InvalidAliasFormat(req.short_code),
549            ));
550        }
551
552        if !is_valid_alias(&req.short_code) {
553            return Err(UrlShortenerError::Validation(
554                ValidationError::InvalidAliasFormat(req.short_code.clone()),
555            ));
556        }
557
558        let resp = self
559            .client
560            .post(format!(
561                "{}/export/{}/{}",
562                self.base_url, req.short_code, req.export_format
563            ))
564            .form(&req)
565            .send()
566            .map_err(UrlShortenerError::Http)?;
567
568        let status = resp.status();
569        if !status.is_success() {
570            if status.as_u16() == 429 {
571                return Err(UrlShortenerError::Api(ApiError::RateLimitExceeded));
572            }
573
574            let text = resp.text().map_err(UrlShortenerError::Http)?;
575            if let Ok(err_json) = serde_json::from_str::<serde_json::Value>(&text) {
576                if let Some(err) = err_json.get("error").and_then(|e| e.as_str()) {
577                    return Err(UrlShortenerError::Api(match err {
578                        "UrlError" => ApiError::UrlError,
579                        "AliasError" => ApiError::AliasError,
580                        "PasswordError" => ApiError::PasswordError,
581                        "MaxClicksError" => ApiError::MaxClicksError,
582                        "EmojiError" => ApiError::EmojiError,
583                        _ => ApiError::Other(err.to_string()),
584                    }));
585                }
586            }
587            return Err(UrlShortenerError::Other(text));
588        }
589
590        let data = resp.bytes().map_err(UrlShortenerError::Http)?;
591        let result = ExportResponse {
592            data: data.to_vec(),
593        };
594
595        Ok(result)
596    }
597}
598
599impl Default for UrlShortenerClient {
600    fn default() -> Self {
601        Self::new()
602    }
603}