url_preview/
preview_service.rs

1#[cfg(feature = "github")]
2use crate::github_types::{is_github_url, GitHubDetailedInfo};
3use crate::{
4    is_twitter_url, CacheStrategy, Fetcher, Preview, PreviewError, PreviewGenerator,
5    UrlPreviewGenerator,
6};
7use std::sync::Arc;
8use tokio::sync::Semaphore;
9#[cfg(all(feature = "logging", feature = "github"))]
10use tracing::warn;
11#[cfg(feature = "logging")]
12use tracing::{debug, instrument};
13use url::Url;
14
15/// PreviewService provides a unified preview generation service
16/// It can automatically identify different types of URLs and use appropriate processing strategies
17#[derive(Clone)]
18pub struct PreviewService {
19    pub default_generator: Arc<UrlPreviewGenerator>,
20    #[cfg(feature = "twitter")]
21    pub twitter_generator: Arc<UrlPreviewGenerator>,
22    #[cfg(feature = "github")]
23    pub github_generator: Arc<UrlPreviewGenerator>,
24    // Max Concurrent Requests
25    semaphore: Arc<Semaphore>,
26}
27
28pub const MAX_CONCURRENT_REQUESTS: usize = 500;
29
30impl Default for PreviewService {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl PreviewService {
37    /// Creates a new preview service instance with default cache capacity
38    pub fn new() -> Self {
39        // Set 1000 cache entries for each generator
40        // This means that up to 1000 different URL previews can be cached for each type (Normal/Twitter/GitHub)
41        // 1000 cache entries take about 1-2MB memory
42        // Total of 3-6MB for three generators is reasonable for modern systems
43        Self::with_cache_cap(1000)
44    }
45
46    pub fn with_cache_cap(cache_capacity: usize) -> Self {
47        #[cfg(feature = "logging")]
48        debug!(
49            "Initializing PreviewService with cache capacity: {}",
50            cache_capacity
51        );
52
53        let default_generator = Arc::new(UrlPreviewGenerator::new_with_fetcher(
54            cache_capacity,
55            CacheStrategy::UseCache,
56            Fetcher::new(),
57        ));
58
59        #[cfg(feature = "twitter")]
60        let twitter_generator = Arc::new(UrlPreviewGenerator::new_with_fetcher(
61            cache_capacity,
62            CacheStrategy::UseCache,
63            Fetcher::new_twitter_client(),
64        ));
65
66        #[cfg(feature = "github")]
67        let github_generator = Arc::new(UrlPreviewGenerator::new_with_fetcher(
68            cache_capacity,
69            CacheStrategy::UseCache,
70            Fetcher::new_github_client(),
71        ));
72
73        let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_REQUESTS));
74
75        #[cfg(feature = "logging")]
76        debug!("PreviewService initialized successfully");
77
78        Self {
79            default_generator,
80            #[cfg(feature = "twitter")]
81            twitter_generator,
82            #[cfg(feature = "github")]
83            github_generator,
84            semaphore,
85        }
86    }
87
88    pub fn no_cache() -> Self {
89        #[cfg(feature = "logging")]
90        debug!("Initializing PreviewService with cache capacity: {}", 0);
91
92        let default_generator = Arc::new(UrlPreviewGenerator::new_with_fetcher(
93            0,
94            CacheStrategy::NoCache,
95            Fetcher::new(),
96        ));
97
98        #[cfg(feature = "twitter")]
99        let twitter_generator = Arc::new(UrlPreviewGenerator::new_with_fetcher(
100            0,
101            CacheStrategy::NoCache,
102            Fetcher::new_twitter_client(),
103        ));
104
105        #[cfg(feature = "github")]
106        let github_generator = Arc::new(UrlPreviewGenerator::new_with_fetcher(
107            0,
108            CacheStrategy::NoCache,
109            Fetcher::new_github_client(),
110        ));
111
112        let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_REQUESTS));
113
114        #[cfg(feature = "logging")]
115        debug!("PreviewService initialized successfully");
116
117        Self {
118            default_generator,
119            #[cfg(feature = "twitter")]
120            twitter_generator,
121            #[cfg(feature = "github")]
122            github_generator,
123            semaphore,
124        }
125    }
126
127    pub fn new_with_config(config: PreviewServiceConfig) -> Self {
128        #[cfg(feature = "logging")]
129        debug!("Initializing PreviewService with custom configuration");
130
131        let default_generator = Arc::new(UrlPreviewGenerator::new_with_fetcher(
132            config.cache_capacity,
133            config.cache_strategy,
134            config.default_fetcher.unwrap_or_else(Fetcher::new),
135        ));
136
137        #[cfg(feature = "twitter")]
138        let twitter_generator = Arc::new(UrlPreviewGenerator::new_with_fetcher(
139            config.cache_capacity,
140            config.cache_strategy,
141            config
142                .twitter_fetcher
143                .unwrap_or_else(Fetcher::new_twitter_client),
144        ));
145
146        #[cfg(feature = "github")]
147        let github_generator = Arc::new(UrlPreviewGenerator::new_with_fetcher(
148            config.cache_capacity,
149            config.cache_strategy,
150            config
151                .github_fetcher
152                .unwrap_or_else(Fetcher::new_github_client),
153        ));
154
155        let semaphore = Arc::new(Semaphore::new(config.max_concurrent_requests));
156
157        #[cfg(feature = "logging")]
158        debug!("PreviewService initialized with custom configuration");
159
160        Self {
161            default_generator,
162            #[cfg(feature = "twitter")]
163            twitter_generator,
164            #[cfg(feature = "github")]
165            github_generator,
166            semaphore,
167        }
168    }
169
170    #[cfg(feature = "github")]
171    fn extract_github_info(url: &str) -> Option<(String, String)> {
172        let parsed_url = Url::parse(url).ok()?;
173        if !parsed_url.host_str()?.contains("github.com") {
174            return None;
175        }
176
177        let path_segments: Vec<&str> = parsed_url.path_segments()?.collect();
178        if path_segments.len() >= 2 {
179            return Some((path_segments[0].to_string(), path_segments[1].to_string()));
180        }
181        None
182    }
183
184    #[cfg(feature = "github")]
185    #[cfg_attr(feature = "logging", instrument(level = "debug", skip(self)))]
186    async fn generate_github_preview(&self, url: &str) -> Result<Preview, PreviewError> {
187        #[cfg(feature = "cache")]
188        if let CacheStrategy::UseCache = self.github_generator.cache_strategy {
189            if let Some(cached) = self.github_generator.cache.get(url).await {
190                return Ok(cached);
191            }
192        }
193
194        let (owner, repo_name) = Self::extract_github_info(url).ok_or_else(|| {
195            #[cfg(feature = "logging")]
196            warn!("GitHub URL parsing failed: {}", url);
197            PreviewError::ExtractError("Invalid GitHub URL format".into())
198        })?;
199
200        match self
201            .github_generator
202            .fetcher
203            .fetch_github_basic_preview(&owner, &repo_name)
204            .await
205        {
206            Ok(basic_info) => {
207                #[cfg(feature = "logging")]
208                debug!("Found GitHub Repo {}/{} basic infos", owner, repo_name);
209
210                let preview = Preview {
211                    url: url.to_string(),
212                    title: basic_info.title,
213                    description: basic_info.description,
214                    image_url: basic_info.image_url,
215                    site_name: Some("GitHub".to_string()),
216                    favicon: Some(
217                        "https://github.githubassets.com/favicons/favicon.svg".to_string(),
218                    ),
219                };
220
221                #[cfg(feature = "cache")]
222                if let CacheStrategy::UseCache = self.github_generator.cache_strategy {
223                    self.github_generator
224                        .cache
225                        .set(url.to_string(), preview.clone())
226                        .await;
227                }
228
229                Ok(preview)
230            }
231            Err(_e) => {
232                #[cfg(feature = "logging")]
233                warn!(
234                    error = ?_e,
235                    "Failed to get GitHub basic preview, will use general preview generator as fallback"
236                );
237                self.github_generator.generate_preview(url).await
238            }
239        }
240    }
241
242    #[cfg_attr(feature = "logging", instrument(level = "debug", skip(self)))]
243    pub async fn generate_preview(&self, url: &str) -> Result<Preview, PreviewError> {
244        #[cfg(feature = "logging")]
245        debug!("Starting preview generation for URL: {}", url);
246
247        let _permit = self
248            .semaphore
249            .acquire()
250            .await
251            .map_err(|_| PreviewError::ConcurrencyLimitError)?;
252
253        let _ = Url::parse(url)
254            .map_err(|e| PreviewError::ParseError(format!("Invalid URL format: {}", e)))?;
255
256        if is_twitter_url(url) {
257            #[cfg(feature = "logging")]
258            debug!("Detected Twitter URL, using specialized handler");
259            #[cfg(feature = "twitter")]
260            {
261                self.twitter_generator.generate_preview(url).await
262            }
263            #[cfg(not(feature = "twitter"))]
264            {
265                self.default_generator.generate_preview(url).await
266            }
267        } else if cfg!(feature = "github") && {
268            #[cfg(feature = "github")]
269            {
270                is_github_url(url)
271            }
272            #[cfg(not(feature = "github"))]
273            {
274                false
275            }
276        } {
277            #[cfg(feature = "logging")]
278            debug!("Detected GitHub URL, using specialized handler");
279            #[cfg(feature = "github")]
280            {
281                self.generate_github_preview(url).await
282            }
283            #[cfg(not(feature = "github"))]
284            {
285                self.default_generator.generate_preview(url).await
286            }
287        } else {
288            #[cfg(feature = "logging")]
289            debug!("Using default URL handler");
290            self.default_generator.generate_preview(url).await
291        }
292    }
293
294    #[cfg_attr(feature = "logging", instrument(level = "debug", skip(self)))]
295    pub async fn generate_preview_with_concurrency(
296        &self,
297        url: &str,
298    ) -> Result<Preview, PreviewError> {
299        #[cfg(feature = "logging")]
300        debug!("Starting preview generation for URL: {}", url);
301
302        let _permit = self
303            .semaphore
304            .acquire()
305            .await
306            .map_err(|_| PreviewError::ConcurrencyLimitError)?;
307
308        let _ = Url::parse(url)
309            .map_err(|e| PreviewError::ParseError(format!("Invalid URL format: {}", e)))?;
310
311        if is_twitter_url(url) {
312            #[cfg(feature = "logging")]
313            debug!("Detected Twitter URL, using specialized handler");
314            #[cfg(feature = "twitter")]
315            {
316                self.twitter_generator.generate_preview(url).await
317            }
318            #[cfg(not(feature = "twitter"))]
319            {
320                self.default_generator.generate_preview(url).await
321            }
322        } else if cfg!(feature = "github") && {
323            #[cfg(feature = "github")]
324            {
325                is_github_url(url)
326            }
327            #[cfg(not(feature = "github"))]
328            {
329                false
330            }
331        } {
332            #[cfg(feature = "logging")]
333            debug!("Detected GitHub URL, using specialized handler");
334            #[cfg(feature = "github")]
335            {
336                self.generate_github_preview(url).await
337            }
338            #[cfg(not(feature = "github"))]
339            {
340                self.default_generator.generate_preview(url).await
341            }
342        } else {
343            #[cfg(feature = "logging")]
344            debug!("Using default URL handler");
345            self.default_generator.generate_preview(url).await
346        }
347    }
348
349    #[cfg(feature = "github")]
350    pub async fn generate_github_basic_preview(&self, url: &str) -> Result<Preview, PreviewError> {
351        let (owner, repo) = Self::extract_github_info(url)
352            .ok_or_else(|| PreviewError::ExtractError("Invalid GitHub URL format".into()))?;
353
354        let basic_info = self
355            .github_generator
356            .fetcher
357            .fetch_github_basic_preview(&owner, &repo)
358            .await?;
359
360        Ok(Preview {
361            url: url.to_string(),
362            title: basic_info.title,
363            description: basic_info.description,
364            image_url: basic_info.image_url,
365            site_name: Some("GitHub".to_string()),
366            favicon: Some("https://github.githubassets.com/favicons/favicon.svg".to_string()),
367        })
368    }
369
370    #[cfg(feature = "github")]
371    pub async fn get_github_detailed_info(
372        &self,
373        url: &str,
374    ) -> Result<GitHubDetailedInfo, PreviewError> {
375        let (owner, repo) = Self::extract_github_info(url)
376            .ok_or_else(|| PreviewError::ExtractError("Invalid GitHub URL format".into()))?;
377
378        self.github_generator
379            .fetcher
380            .fetch_github_detailed_info(&owner, &repo)
381            .await
382    }
383}
384
385/// Static constructor methods
386impl PreviewService {
387    /// Create a new preview service for unit testing
388    pub fn new_minimal() -> Self {
389        let default_generator = Arc::new(UrlPreviewGenerator::new(100, CacheStrategy::UseCache));
390        #[cfg(feature = "twitter")]
391        let twitter_generator = Arc::new(UrlPreviewGenerator::new(100, CacheStrategy::UseCache));
392        #[cfg(feature = "github")]
393        let github_generator = Arc::new(UrlPreviewGenerator::new(100, CacheStrategy::UseCache));
394
395        Self {
396            default_generator,
397            #[cfg(feature = "twitter")]
398            twitter_generator,
399            #[cfg(feature = "github")]
400            github_generator,
401            semaphore: Arc::new(Semaphore::new(10)),
402        }
403    }
404
405    #[cfg_attr(feature = "logging", instrument(level = "debug", skip(self)))]
406    pub async fn generate_preview_no_cache(&self, url: &str) -> Result<Preview, PreviewError> {
407        let generator = UrlPreviewGenerator::new_with_fetcher(
408            0,
409            CacheStrategy::NoCache,
410            self.default_generator.fetcher.clone(),
411        );
412        generator.generate_preview(url).await
413    }
414}
415
416pub struct PreviewServiceConfig {
417    pub cache_capacity: usize,
418    pub cache_strategy: CacheStrategy,
419    pub max_concurrent_requests: usize,
420    pub default_fetcher: Option<Fetcher>,
421    #[cfg(feature = "twitter")]
422    pub twitter_fetcher: Option<Fetcher>,
423    #[cfg(feature = "github")]
424    pub github_fetcher: Option<Fetcher>,
425}
426
427impl PreviewServiceConfig {
428    pub fn new(cache_capacity: usize) -> Self {
429        Self {
430            cache_capacity,
431            cache_strategy: CacheStrategy::UseCache,
432            max_concurrent_requests: MAX_CONCURRENT_REQUESTS,
433            default_fetcher: None,
434            #[cfg(feature = "twitter")]
435            twitter_fetcher: None,
436            #[cfg(feature = "github")]
437            github_fetcher: None,
438        }
439    }
440
441    #[cfg(feature = "github")]
442    pub fn with_github_fetcher(mut self, fetcher: Fetcher) -> Self {
443        self.github_fetcher = Some(fetcher);
444        self
445    }
446
447    pub fn with_default_fetcher(mut self, fetcher: Fetcher) -> Self {
448        self.default_fetcher = Some(fetcher);
449        self
450    }
451
452    #[cfg(feature = "twitter")]
453    pub fn with_twitter_fetcher(mut self, fetcher: Fetcher) -> Self {
454        self.twitter_fetcher = Some(fetcher);
455        self
456    }
457
458    pub fn with_max_concurrent_requests(mut self, max_concurrent_requests: usize) -> Self {
459        self.max_concurrent_requests = max_concurrent_requests;
460        self
461    }
462
463    pub fn with_cache_strategy(mut self, cache_strategy: CacheStrategy) -> Self {
464        self.cache_strategy = cache_strategy;
465        self
466    }
467}