bpi-rs 0.2.0

Bilibili API client library for Rust
Documentation
use crate::search::hot::{DefaultSearchData, HotWordDataResponse};
use crate::search::result::{
    Article, Bangumi, BiliUser, LiveData, LiveRoom, LiveUser, Movie, SearchData, Video,
};
use crate::search::search_params::{
    SearchArticleParams, SearchBangumiParams, SearchBiliUserParams, SearchLiveParams,
    SearchLiveRoomParams, SearchLiveUserParams, SearchMovieParams, SearchVideoParams,
};
use crate::search::suggest::{SearchSuggest, SearchSuggestParams};
use crate::{BilibiliRequest, BpiClient, BpiResult};

const TYPED_ENDPOINT: &str = "https://api.bilibili.com/x/web-interface/wbi/search/type";
const DEFAULT_ENDPOINT: &str = "https://api.bilibili.com/x/web-interface/wbi/search/default";
const SUGGEST_ENDPOINT: &str = "https://s.search.bilibili.com/main/suggest";
const HOTWORDS_ENDPOINT: &str = "https://s.search.bilibili.com/main/hotword";

/// Search API client.
#[derive(Clone, Copy)]
pub struct SearchClient<'a> {
    pub(crate) client: &'a BpiClient,
}

impl<'a> SearchClient<'a> {
    pub(crate) fn new(client: &'a BpiClient) -> Self {
        Self { client }
    }

    #[cfg(test)]
    pub(crate) fn typed_endpoint(&self) -> &'static str {
        TYPED_ENDPOINT
    }

    #[cfg(test)]
    pub(crate) fn default_endpoint(&self) -> &'static str {
        DEFAULT_ENDPOINT
    }

    #[cfg(test)]
    pub(crate) fn suggest_endpoint(&self) -> &'static str {
        SUGGEST_ENDPOINT
    }

    #[cfg(test)]
    pub(crate) fn hotwords_endpoint(&self) -> &'static str {
        HOTWORDS_ENDPOINT
    }

    /// Searches article results.
    pub async fn article(
        &self,
        params: SearchArticleParams,
    ) -> BpiResult<SearchData<Vec<Article>>> {
        self.typed_search(params.query_pairs(), "search.article")
            .await
    }

    /// Searches bangumi results.
    pub async fn bangumi(
        &self,
        params: SearchBangumiParams,
    ) -> BpiResult<SearchData<Vec<Bangumi>>> {
        self.typed_search(params.query_pairs(), "search.bangumi")
            .await
    }

    /// Searches Bilibili user results.
    pub async fn bili_user(
        &self,
        params: SearchBiliUserParams,
    ) -> BpiResult<SearchData<Vec<BiliUser>>> {
        self.typed_search(params.query_pairs(), "search.bili_user")
            .await
    }

    /// Searches combined live room and live user results.
    pub async fn live(&self, params: SearchLiveParams) -> BpiResult<SearchData<LiveData>> {
        self.typed_search(params.query_pairs(), "search.live").await
    }

    /// Searches live room results.
    pub async fn live_room(
        &self,
        params: SearchLiveRoomParams,
    ) -> BpiResult<SearchData<Vec<LiveRoom>>> {
        self.typed_search(params.query_pairs(), "search.live_room")
            .await
    }

    /// Searches live user results.
    pub async fn live_user(
        &self,
        params: SearchLiveUserParams,
    ) -> BpiResult<SearchData<Vec<LiveUser>>> {
        self.typed_search(params.query_pairs(), "search.live_user")
            .await
    }

    /// Searches movie and film results.
    pub async fn movie(&self, params: SearchMovieParams) -> BpiResult<SearchData<Vec<Movie>>> {
        self.typed_search(params.query_pairs(), "search.movie")
            .await
    }

    /// Searches video results.
    pub async fn video(&self, params: SearchVideoParams) -> BpiResult<SearchData<Vec<Video>>> {
        self.typed_search(params.query_pairs(), "search.video")
            .await
    }

    /// Gets the default web search content.
    pub async fn default(&self) -> BpiResult<DefaultSearchData> {
        let signed_params = self.client.get_wbi_sign2(vec![("foo", "bar")]).await?;

        self.client
            .get(DEFAULT_ENDPOINT)
            .query(&signed_params)
            .send_bpi_payload("search.default")
            .await
    }

    /// Gets search suggestions for a term.
    pub async fn suggest(&self, params: SearchSuggestParams) -> BpiResult<SearchSuggest> {
        self.client
            .get(SUGGEST_ENDPOINT)
            .query(&params.query_pairs())
            .send_bpi_payload("search.suggest")
            .await
    }

    /// Gets the web hotword list.
    pub async fn hotwords(&self) -> BpiResult<HotWordDataResponse> {
        let response = self.client.get(HOTWORDS_ENDPOINT).send().await?;

        Ok(response.json().await?)
    }

    async fn typed_search<T>(
        &self,
        query_pairs: Vec<(&'static str, String)>,
        endpoint_label: &'static str,
    ) -> BpiResult<SearchData<T>>
    where
        T: serde::de::DeserializeOwned,
    {
        let signed_params = self.client.get_wbi_sign2(query_pairs).await?;

        self.client
            .get(TYPED_ENDPOINT)
            .query(&signed_params)
            .send_bpi_payload(endpoint_label)
            .await
    }
}

#[cfg(test)]
mod tests {
    use std::future::Future;

    use crate::probe::contract::HttpMethod;
    use crate::probe::endpoint_contract::EndpointContract;
    use crate::search::hot::{DefaultSearchData, HotWordDataResponse};
    use crate::search::result::{
        Article, Bangumi, BiliUser, LiveData, LiveRoom, LiveUser, Movie, SearchData, Video,
    };
    use crate::search::search_params::{
        SearchArticleParams, SearchBangumiParams, SearchBiliUserParams, SearchLiveParams,
        SearchLiveRoomParams, SearchLiveUserParams, SearchMovieParams, SearchVideoParams,
    };
    use crate::search::suggest::{SearchSuggest, SearchSuggestParams};
    use crate::{BpiClient, BpiResult};

    fn assert_article_future<F>(_future: F)
    where
        F: Future<Output = BpiResult<SearchData<Vec<Article>>>>,
    {
    }

    fn assert_bangumi_future<F>(_future: F)
    where
        F: Future<Output = BpiResult<SearchData<Vec<Bangumi>>>>,
    {
    }

    fn assert_bili_user_future<F>(_future: F)
    where
        F: Future<Output = BpiResult<SearchData<Vec<BiliUser>>>>,
    {
    }

    fn assert_live_future<F>(_future: F)
    where
        F: Future<Output = BpiResult<SearchData<LiveData>>>,
    {
    }

    fn assert_live_room_future<F>(_future: F)
    where
        F: Future<Output = BpiResult<SearchData<Vec<LiveRoom>>>>,
    {
    }

    fn assert_live_user_future<F>(_future: F)
    where
        F: Future<Output = BpiResult<SearchData<Vec<LiveUser>>>>,
    {
    }

    fn assert_movie_future<F>(_future: F)
    where
        F: Future<Output = BpiResult<SearchData<Vec<Movie>>>>,
    {
    }

    fn assert_video_future<F>(_future: F)
    where
        F: Future<Output = BpiResult<SearchData<Vec<Video>>>>,
    {
    }

    fn assert_default_future<F>(_future: F)
    where
        F: Future<Output = BpiResult<DefaultSearchData>>,
    {
    }

    fn assert_suggest_future<F>(_future: F)
    where
        F: Future<Output = BpiResult<SearchSuggest>>,
    {
    }

    fn assert_hotwords_future<F>(_future: F)
    where
        F: Future<Output = BpiResult<HotWordDataResponse>>,
    {
    }

    fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
        let bytes = match endpoint {
            "article" => {
                include_bytes!("../../tests/contracts/search/read/article/contract.json").as_slice()
            }
            "bangumi" => {
                include_bytes!("../../tests/contracts/search/read/bangumi/contract.json").as_slice()
            }
            "bili-user" => {
                include_bytes!("../../tests/contracts/search/read/bili-user/contract.json")
                    .as_slice()
            }
            "live" => {
                include_bytes!("../../tests/contracts/search/read/live/contract.json").as_slice()
            }
            "live-room" => {
                include_bytes!("../../tests/contracts/search/read/live-room/contract.json")
                    .as_slice()
            }
            "live-user" => {
                include_bytes!("../../tests/contracts/search/read/live-user/contract.json")
                    .as_slice()
            }
            "movie" => {
                include_bytes!("../../tests/contracts/search/read/movie/contract.json").as_slice()
            }
            "video" => {
                include_bytes!("../../tests/contracts/search/read/video/contract.json").as_slice()
            }
            "default" => {
                include_bytes!("../../tests/contracts/search/read/default/contract.json").as_slice()
            }
            "suggest" => {
                include_bytes!("../../tests/contracts/search/read/suggest/contract.json").as_slice()
            }
            "hotwords" => {
                include_bytes!("../../tests/contracts/search/read/hotwords/contract.json")
                    .as_slice()
            }
            _ => unreachable!("unknown search contract"),
        };
        EndpointContract::from_slice(bytes)
    }

    #[test]
    fn search_client_exposes_promoted_endpoint_urls() -> BpiResult<()> {
        let client = BpiClient::new()?;
        let search = client.search();

        assert_eq!(
            search.typed_endpoint(),
            "https://api.bilibili.com/x/web-interface/wbi/search/type"
        );
        assert_eq!(
            search.default_endpoint(),
            "https://api.bilibili.com/x/web-interface/wbi/search/default"
        );
        assert_eq!(
            search.suggest_endpoint(),
            "https://s.search.bilibili.com/main/suggest"
        );
        assert_eq!(
            search.hotwords_endpoint(),
            "https://s.search.bilibili.com/main/hotword"
        );
        Ok(())
    }

    #[test]
    fn search_methods_return_payload_futures() -> BpiResult<()> {
        let client = BpiClient::new()?;
        let search = client.search();

        assert_article_future(search.article(SearchArticleParams::new("rust")?));
        assert_bangumi_future(search.bangumi(SearchBangumiParams::new("天气之子")?));
        assert_bili_user_future(search.bili_user(SearchBiliUserParams::new("老番茄")?));
        assert_live_future(search.live(SearchLiveParams::new("游戏")?));
        assert_live_room_future(search.live_room(SearchLiveRoomParams::new("游戏")?));
        assert_live_user_future(search.live_user(SearchLiveUserParams::new("散人")?));
        assert_movie_future(search.movie(SearchMovieParams::new("哈利波特")?));
        assert_video_future(search.video(SearchVideoParams::new("rust")?));
        assert_default_future(search.default());
        assert_suggest_future(search.suggest(SearchSuggestParams::new("rust")?));
        assert_hotwords_future(search.hotwords());
        Ok(())
    }

    #[test]
    fn search_contracts_match_module_client_endpoints() -> BpiResult<()> {
        let client = BpiClient::new()?;
        let search = client.search();

        let typed_expectations = [
            ("article", "search.article"),
            ("bangumi", "search.bangumi"),
            ("bili-user", "search.bili_user"),
            ("live", "search.live"),
            ("live-room", "search.live_room"),
            ("live-user", "search.live_user"),
            ("movie", "search.movie"),
            ("video", "search.video"),
        ];

        for (endpoint, name) in typed_expectations {
            let contract = contract(endpoint)?;

            assert_eq!(contract.name, name);
            assert_eq!(contract.request.method, HttpMethod::Get);
            assert_eq!(contract.request.url.as_str(), search.typed_endpoint());
            assert!(contract.request.auth.requires_wbi());
        }

        let default = contract("default")?;
        assert_eq!(default.name, "search.default");
        assert_eq!(default.request.method, HttpMethod::Get);
        assert_eq!(default.request.url.as_str(), search.default_endpoint());
        assert!(default.request.auth.requires_wbi());

        let suggest = contract("suggest")?;
        assert_eq!(suggest.name, "search.suggest");
        assert_eq!(suggest.request.method, HttpMethod::Get);
        assert_eq!(suggest.request.url.as_str(), search.suggest_endpoint());
        assert!(!suggest.request.auth.requires_wbi());

        let hotwords = contract("hotwords")?;
        assert_eq!(hotwords.name, "search.hotwords");
        assert_eq!(hotwords.request.method, HttpMethod::Get);
        assert_eq!(hotwords.request.url.as_str(), search.hotwords_endpoint());
        assert!(!hotwords.request.auth.requires_wbi());
        Ok(())
    }
}