github_trending_rs/
lib.rs

1#![doc = include_str!("../README.md")]
2
3mod params;
4#[cfg(test)]
5mod tests;
6
7use std::marker::PhantomData;
8
9use params::Param;
10pub use params::{Language, Since, SpokenLanguage};
11
12use soupy::{Node, Queryable};
13use thiserror::Error;
14
15// TODO: trending developers?
16
17/// A trending github repository, may add more fields later
18#[derive(Clone, Debug)]
19pub struct Repository {
20    pub name: String,
21    pub owner: String,
22    pub description: String,
23    // TODO: extract more fields from response
24    // pub stars: usize,
25    // pub forks: usize,
26    // pub stars_since: usize,
27    // pub language: String,
28    // pub contributors: Vec<String>,
29}
30
31impl Repository {
32    /// returns the github repository url
33    pub fn url(&self) -> String {
34        format!("https://github.com/{}/{}", self.owner, self.name)
35    }
36}
37
38#[derive(Debug, Error)]
39pub enum TrendingError<E>
40where
41    E: std::error::Error,
42{
43    #[error("Request Error: {0}")]
44    RequestError(#[from] E),
45}
46
47/// this trait is an attempt to support multiple HTTP client backend,
48/// also we only need parameters for requesting trending information,
49/// so we use extension trait to let user requesting trending information
50/// with corresponding HTTP client directly
51/// for simplicity, we only support async HTTP client
52/// TODO: maybe this should be a sealed trait?
53pub trait TrendExt<'c> {
54    type BackendError: std::error::Error;
55    // start a trending query
56    fn github_trending(&'c self) -> TrendingBuilder<'c, Self>;
57    #[doc(hidden)]
58    fn request_trending(
59        &self,
60        request: TrendingRequest,
61    ) -> impl std::future::Future<Output = Result<String, TrendingError<Self::BackendError>>> + Send;
62}
63
64impl<'c> TrendExt<'c> for reqwest::Client {
65    type BackendError = reqwest::Error;
66
67    fn github_trending(&'c self) -> TrendingBuilder<'c, Self> {
68        TrendingBuilder {
69            client: self,
70            parameters: TrendingParameters::default(),
71        }
72    }
73
74    async fn request_trending(
75        &self,
76        TrendingRequest { url, parameters }: TrendingRequest,
77    ) -> Result<String, TrendingError<reqwest::Error>> {
78        Ok(self
79            .get(url)
80            .query(&parameters)
81            .send()
82            .await?
83            .text()
84            .await?)
85    }
86}
87
88#[derive(Debug, Clone, Default)]
89pub struct TrendingRequest {
90    url: String,
91    parameters: Vec<(&'static str, &'static str)>,
92}
93
94#[derive(Debug, Clone, Copy, Default)]
95pub struct TrendingParameters {
96    lang: Option<Language>,
97    spoken: Option<SpokenLanguage>,
98    since: Option<Since>,
99}
100
101impl TrendingParameters {
102    pub fn with_language(&mut self, lang: Language) -> &mut Self {
103        self.lang.replace(lang);
104        self
105    }
106
107    pub fn since(&mut self, since: Since) -> &mut Self {
108        self.since.replace(since);
109        self
110    }
111
112    pub fn with_spoken_language(&mut self, spoken_language: SpokenLanguage) -> &mut Self {
113        self.spoken.replace(spoken_language);
114        self
115    }
116}
117
118/// construct a trending query
119pub struct TrendingBuilder<'c, C>
120where
121    C: TrendExt<'c> + ?Sized,
122{
123    client: &'c C,
124    parameters: TrendingParameters,
125}
126
127impl<'c, C> TrendingBuilder<'c, C>
128where
129    C: TrendExt<'c> + ?Sized,
130{
131    /// change the programming language
132    pub fn with_language(&mut self, lang: Language) -> &mut Self {
133        self.parameters.with_language(lang);
134        self
135    }
136
137    /// specify the trending period
138    pub fn since(&mut self, since: Since) -> &mut Self {
139        self.parameters.since(since);
140        self
141    }
142
143    /// change the spoken language
144    pub fn with_spoken_language(&mut self, spoken_language: SpokenLanguage) -> &mut Self {
145        self.parameters.with_spoken_language(spoken_language);
146        self
147    }
148
149    /// construct a query for trending repositories
150    pub async fn repositories(
151        &self,
152    ) -> Result<Trending<Repository>, TrendingError<C::BackendError>> {
153        let request = Repository::request(self.parameters);
154        let resp = self.client.request_trending(request).await?;
155        Ok(Trending {
156            raw: resp,
157            _t: PhantomData,
158        })
159    }
160}
161
162// TODO: should be private or sealed?
163/// this trait represent different kind trending information,
164/// only repository is implemented at the moment
165pub trait Trend: Sized {
166    /// this method extract a list of trends from the raw HTML string
167    fn extract(text: &str) -> Vec<Self>;
168    /// this method construct a request for query
169    fn request(parameter: TrendingParameters) -> TrendingRequest;
170}
171
172impl Trend for Repository {
173    fn extract(text: &str) -> Vec<Self> {
174        let soup = soupy::Soup::html(text);
175        soup.tag("article")
176            .into_iter()
177            .filter_map(|article| {
178                // any failed parse is silently discarded
179                let url = article.query().tag("h2").first().and_then(|item| {
180                    item.query()
181                        .tag("a")
182                        .first()
183                        .and_then(|item| item.get("href").cloned())
184                        .and_then(|url| {
185                            url.strip_prefix("/").and_then(|url| {
186                                url.split_once("/")
187                                    .map(|(owner, name)| (owner.to_string(), name.to_string()))
188                            })
189                        })
190                });
191                let desc = article
192                    .query()
193                    .tag("p")
194                    .first()
195                    .map(|item| item.all_text().trim().to_string());
196                url.zip(desc)
197                    .map(|((owner, name), description)| Repository {
198                        name,
199                        owner,
200                        description,
201                    })
202            })
203            .collect()
204    }
205
206    fn request(param: TrendingParameters) -> TrendingRequest {
207        let url = match param.lang {
208            Some(lang) => format!("https://github.com/trending/{}", lang.value()),
209            None => "https://github.com/trending".to_string(),
210        };
211        let parameters: Vec<_> = param
212            .since
213            .iter()
214            .map(Since::query)
215            .chain(param.spoken.iter().map(SpokenLanguage::query))
216            .collect();
217
218        TrendingRequest { url, parameters }
219    }
220}
221
222pub struct Trending<T: Trend> {
223    raw: String,
224    _t: PhantomData<T>,
225}
226
227impl<T: Trend> Trending<T> {
228    /// returns the list of every trends
229    pub fn all(&self) -> Vec<T> {
230        T::extract(&self.raw)
231    }
232
233    // returns the raw HTML response
234    pub fn raw(&self) -> &str {
235        &self.raw
236    }
237}