1use 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
11pub 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 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 #[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 #[cfg(feature = "custom_url")]
71 pub fn set_base_url(&mut self, url: &str) {
72 self.base_url = url.to_string();
73 }
74
75 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}