use rayon::prelude::*; use reqwest::header; use reqwest::Client; use reqwest::Url; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use crate::error::Result; use crate::tui::markdown; /// StackExchange API v2.2 URL // TODO why not https? const SE_API_URL: &str = "http://api.stackexchange.com"; const SE_API_VERSION: &str = "2.2"; /// Filter generated to include only the fields needed to populate /// the structs below. Go here to make new filters: /// [create filter](https://api.stackexchange.com/docs/create-filter). const SE_FILTER: &str = ".DND5X2VHHUH8HyJzpjo)5NvdHI3w6auG"; /// Pagesize when fetching all SE sites. Should be good for many years... const SE_SITES_PAGESIZE: u16 = 10000; /// Represents a StackExchange answer with a custom selection of fields from /// the [StackExchange docs](https://api.stackexchange.com/docs/types/answer) #[derive(Clone, Deserialize, Debug)] pub struct Answer { #[serde(rename = "answer_id")] pub id: u32, pub score: i32, #[serde(rename = "body_markdown")] pub body: S, pub is_accepted: bool, } /// Represents a StackExchange question with a custom selection of fields from /// the [StackExchange docs](https://api.stackexchange.com/docs/types/question) // TODO container over answers should be generic iterator #[derive(Clone, Deserialize, Debug)] pub struct Question { #[serde(rename = "question_id")] pub id: u32, pub score: i32, #[serde(default = "Vec::new")] // N.B. empty vector default needed because questions endpoint cannot filter // answers >= 1 pub answers: Vec>, pub title: String, #[serde(rename = "body_markdown")] pub body: S, } /// Internal struct that represents the boilerplate response wrapper from SE API. #[derive(Deserialize, Debug)] struct ResponseWrapper { items: Vec, } #[derive(Deserialize, Serialize, Debug)] pub struct Site { pub api_site_parameter: String, pub site_url: String, } #[derive(Clone)] pub struct Api { client: Client, api_key: Option, } impl Api { pub fn new(api_key: Option) -> Self { // TODO can lazy_static this above let mut headers = header::HeaderMap::new(); headers.insert( header::ACCEPT, header::HeaderValue::from_static("application/json"), ); let client = Client::builder().default_headers(headers).build().unwrap(); Api { client, api_key } } /// Search against the SE site's /questions/{ids} endpoint. /// Filters out questions with no answers. pub async fn questions(&self, site: &str, ids: Vec) -> Result>> { let total = ids.len().to_string(); let endpoint = format!("questions/{ids}", ids = ids.join(";")); let qs = self .client .get(stackexchange_url(&endpoint)) .query(&self.get_default_se_opts()) .query(&[("site", site), ("pagesize", &total)]) .send() .await? .json::>>() .await? .items .into_iter() .filter(|q| !q.answers.is_empty()) .collect(); Ok(Self::preprocess(qs)) } /// Search against the SE site's /search/advanced endpoint with a given query. /// Only fetches questions that have at least one answer. pub async fn search_advanced( &self, query: &str, site: &str, limit: u16, ) -> Result>> { let qs = self .client .get(stackexchange_url("search/advanced")) .query(&self.get_default_se_opts()) .query(&[ ("q", query), ("pagesize", &limit.to_string()), ("site", site), ("answers", "1"), ("order", "desc"), ("sort", "relevance"), ]) .send() .await? .json::>>() .await? .items; Ok(Self::preprocess(qs)) } pub async fn sites(&self) -> Result> { let sites = self .client .get(stackexchange_url("sites")) .query(&[("pagesize", SE_SITES_PAGESIZE.to_string())]) .send() .await? .json::>() .await? .items; Ok(sites .into_par_iter() .map(|site| { let site_url = site.site_url.trim_start_matches("https://").to_string(); Site { site_url, ..site } }) .collect()) } fn get_default_se_opts(&self) -> HashMap<&str, &str> { let mut params = HashMap::new(); params.insert("filter", SE_FILTER); params.insert("page", "1"); if let Some(key) = &self.api_key { params.insert("key", key); } params } /// Sorts answers by score /// Preprocess SE markdown to "cmark" markdown (or something closer to it) /// This markdown preprocess _always_ happens. fn preprocess(qs: Vec>) -> Vec> { qs.into_par_iter() .map(|q| { let mut answers = q.answers; answers.par_sort_unstable_by_key(|a| -a.score); let answers = answers .into_par_iter() .map(|a| Answer { body: markdown::preprocess(a.body.clone()), ..a }) .collect(); Question { answers, body: markdown::preprocess(q.body), ..q } }) .collect::>() } } /// Creates stackexchange API url given endpoint // TODO lazy static this url parse fn stackexchange_url(path: &str) -> Url { let mut url = Url::parse(SE_API_URL).unwrap(); url.path_segments_mut() .unwrap() .push(SE_API_VERSION) .extend(path.split('/')); url } #[cfg(test)] mod tests { use super::*; #[test] fn test_stackexchange_url() { assert_eq!( stackexchange_url("some/endpoint").as_str(), "http://api.stackexchange.com/2.2/some/endpoint" ) } }