github-trending-rs 0.0.5

A simple Rust crate to fetch trending repositories from GitHub.
Documentation
#![doc = include_str!("../README.md")]

mod params;
#[cfg(test)]
mod tests;

use std::marker::PhantomData;

pub use params::Param;
pub use params::{Language, Since, SpokenLanguage};

use soupy::{Node, Queryable};
use thiserror::Error;

// TODO: trending developers?

/// A trending github repository, may add more fields later
#[derive(Clone, Debug)]
pub struct Repository {
    pub name: String,
    pub owner: String,
    pub description: String,
    // TODO: extract more fields from response
    // pub stars: usize,
    // pub forks: usize,
    // pub stars_since: usize,
    // pub language: String,
    // pub contributors: Vec<String>,
}

impl Repository {
    /// returns the github repository url
    pub fn url(&self) -> String {
        format!("https://github.com/{}/{}", self.owner, self.name)
    }
}

#[derive(Debug, Error)]
pub enum TrendingError<E>
where
    E: std::error::Error,
{
    #[error("Request Error: {0}")]
    RequestError(#[from] E),
}

/// this trait is an attempt to support multiple HTTP client backend,
/// also we only need parameters for requesting trending information,
/// so we use extension trait to let user requesting trending information
/// with corresponding HTTP client directly
/// for simplicity, we only support async HTTP client
/// TODO: maybe this should be a sealed trait?
pub trait TrendExt<'c> {
    type BackendError: std::error::Error;
    // start a trending query
    fn github_trending(&'c self) -> TrendingBuilder<'c, Self> {
        TrendingBuilder {
            client: self,
            parameters: TrendingParameters::default(),
        }
    }

    #[doc(hidden)]
    fn request_trending(
        &self,
        request: TrendingRequest,
    ) -> impl std::future::Future<Output = Result<String, TrendingError<Self::BackendError>>> + Send;
}

#[cfg(feature = "reqwest")]
impl<'c> TrendExt<'c> for reqwest::Client {
    type BackendError = reqwest::Error;

    async fn request_trending(
        &self,
        TrendingRequest { url, parameters }: TrendingRequest,
    ) -> Result<String, TrendingError<Self::BackendError>> {
        Ok(self
            .get(url)
            .query(&parameters)
            .send()
            .await?
            .text()
            .await?)
    }
}

#[derive(Debug, Clone, Default)]
pub struct TrendingRequest {
    pub url: String,
    pub parameters: Vec<(&'static str, &'static str)>,
}

#[derive(Debug, Clone, Copy, Default)]
pub struct TrendingParameters {
    lang: Option<Language>,
    spoken: Option<SpokenLanguage>,
    since: Option<Since>,
}

impl TrendingParameters {
    pub fn with_language(&mut self, lang: Language) -> &mut Self {
        self.lang.replace(lang);
        self
    }

    pub fn since(&mut self, since: Since) -> &mut Self {
        self.since.replace(since);
        self
    }

    pub fn with_spoken_language(&mut self, spoken_language: SpokenLanguage) -> &mut Self {
        self.spoken.replace(spoken_language);
        self
    }
}

/// construct a trending query
pub struct TrendingBuilder<'c, C>
where
    C: TrendExt<'c> + ?Sized,
{
    client: &'c C,
    parameters: TrendingParameters,
}

impl<'c, C> TrendingBuilder<'c, C>
where
    C: TrendExt<'c> + ?Sized,
{
    /// change the programming language
    pub fn with_language(&mut self, lang: Language) -> &mut Self {
        self.parameters.with_language(lang);
        self
    }

    /// specify the trending period
    pub fn since(&mut self, since: Since) -> &mut Self {
        self.parameters.since(since);
        self
    }

    /// change the spoken language
    pub fn with_spoken_language(&mut self, spoken_language: SpokenLanguage) -> &mut Self {
        self.parameters.with_spoken_language(spoken_language);
        self
    }

    /// construct a query for trending repositories
    pub async fn repositories(
        &self,
    ) -> Result<Trending<Repository>, TrendingError<C::BackendError>> {
        let request = Repository::request(self.parameters);
        let resp = self.client.request_trending(request).await?;
        Ok(Trending {
            raw: resp,
            _t: PhantomData,
        })
    }
}

// TODO: should be private or sealed?
/// this trait represent different kind trending information,
/// only repository is implemented at the moment
pub trait Trend: Sized {
    /// this method extract a list of trends from the raw HTML string
    fn extract(text: &str) -> Vec<Self>;
    /// this method construct a request for query
    fn request(parameter: TrendingParameters) -> TrendingRequest;
}

impl Trend for Repository {
    fn extract(text: &str) -> Vec<Self> {
        let soup = soupy::Soup::html(text);
        soup.tag("article")
            .into_iter()
            .filter_map(|article| {
                // any failed parse is silently discarded
                let url = article.query().tag("h2").first().and_then(|item| {
                    item.query()
                        .tag("a")
                        .first()
                        .and_then(|item| item.get("href").cloned())
                        .and_then(|url| {
                            url.strip_prefix("/").and_then(|url| {
                                url.split_once("/")
                                    .map(|(owner, name)| (owner.to_string(), name.to_string()))
                            })
                        })
                });
                let desc = article
                    .query()
                    .tag("p")
                    .first()
                    .map(|item| item.all_text().trim().to_string());
                url.zip(desc)
                    .map(|((owner, name), description)| Repository {
                        name,
                        owner,
                        description,
                    })
            })
            .collect()
    }

    fn request(param: TrendingParameters) -> TrendingRequest {
        let url = match param.lang {
            Some(lang) => format!("https://github.com/trending/{}", lang.value()),
            None => "https://github.com/trending".to_string(),
        };
        let parameters: Vec<_> = param
            .since
            .iter()
            .map(Since::query)
            .chain(param.spoken.iter().map(SpokenLanguage::query))
            .collect();

        TrendingRequest { url, parameters }
    }
}

pub struct Trending<T: Trend> {
    raw: String,
    _t: PhantomData<T>,
}

impl<T: Trend> Trending<T> {
    /// returns the list of every trends
    pub fn all(&self) -> Vec<T> {
        T::extract(&self.raw)
    }

    // returns the raw HTML response
    pub fn raw(&self) -> &str {
        &self.raw
    }
}