diff --git a/Cargo.toml b/Cargo.toml index 95fb6c0..ce8af18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,4 +10,5 @@ edition = "2018" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } +tokio-test = "0.4" reqwest = { version = "0.11", features = ["json"] } diff --git a/README.md b/README.md index 4e43cd2..9878bc1 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,23 @@ # podcast-api-rust Rust library for the Listen Notes Podcast API + +## Check + - formatting: `cargo fmt -- --check` + - valid code: `cargo check` + - linting: `cargo clippy` + +## Open Docs +`cargo doc --open` + +## Build +`cargo build` + +## Test +`cargo test` + +## Usage +Add this to your `Cargo.toml`: +```toml +[dependencies] +podcast-api = "0.1" +``` \ No newline at end of file diff --git a/src/api.rs b/src/api.rs index 32790ba..ea5d479 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,9 +1,12 @@ -pub enum Api { - Production(String), +/// API url and key context. +pub enum Api<'a> { + /// API context for Listen Notes production API. + Production(&'a str), + /// API context for Listen Notes mock API for testing. Mock, } -impl Api { +impl Api<'_> { pub fn url(&self) -> &str { match &self { Api::Production(_) => "https://listen-api.listennotes.com/api/v2", diff --git a/src/client.rs b/src/client.rs index 89e7960..2b3c47e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,13 +1,27 @@ use super::{Api, Result}; -use serde_json::{json, Value}; +use reqwest::RequestBuilder; +use serde_json::Value; -pub struct Client { +/// Client for accessing Listen Notes API. +pub struct Client<'a> { + /// HTTP client. client: reqwest::Client, - api: Api, + /// API context. + api: Api<'a>, } -impl Client { - pub fn new(client: reqwest::Client, id: Option) -> Client { +impl Client<'_> { + /// Creates new Listen API Client. + /// + /// To access production API: + /// ``` + /// let client = podcast_api::Client::new(reqwest::Client::new(), Some("YOUR-API-KEY")); + /// ``` + /// To access mock API: + /// ``` + /// let client = podcast_api::Client::new(reqwest::Client::new(), None); + /// ``` + pub fn new(client: reqwest::Client, id: Option<&str>) -> Client { Client { client, api: if let Some(id) = id { @@ -18,59 +32,105 @@ impl Client { } } + /// Calls [`GET /search`](https://www.listennotes.com/api/docs/#get-api-v2-search) with supplied parameters. pub async fn search(&self, parameters: &Value) -> Result { self.get("search", parameters).await } + /// Calls [`GET /typeahead`](https://www.listennotes.com/api/docs/#get-api-v2-typeahead) with supplied parameters. pub async fn typeahead(&self, parameters: &Value) -> Result { self.get("typeahead", parameters).await } - pub async fn episode_by_id(&self, id: &str, parameters: &Value) -> Result { - self.get(&format!("episodes/{}", id), parameters).await + /// Calls [`GET /best_podcasts`](https://www.listennotes.com/api/docs/#get-api-v2-best_podcasts) with supplied parameters. + pub async fn fetch_best_podcasts(&self, parameters: &Value) -> Result { + self.get("best_podcasts", parameters).await + } + + /// Calls [`GET /podcasts/{id}`](https://www.listennotes.com/api/docs/#get-api-v2-podcasts-id) with supplied parameters. + pub async fn fetch_podcast_by_id(&self, id: &str, parameters: &Value) -> Result { + self.get(&format!("podcasts/{}", id), parameters).await } - pub async fn episodes(&self, ids: &[&str], parameters: &Value) -> Result { - self.post("episodes", ¶meters.with("ids", &ids.join(",").as_str())) - .await + /// Calls [`POST /podcasts`](https://www.listennotes.com/api/docs/#post-api-v2-podcasts) with supplied parameters. + pub async fn batch_fetch_podcasts(&self, parameters: &Value) -> Result { + self.post("podcasts", parameters).await } - pub async fn genres(&self, parameters: &Value) -> Result { - self.get("genres", parameters).await + /// Calls [`GET /episodes/{id}`](https://www.listennotes.com/api/docs/#get-api-v2-episodes-id) with supplied parameters. + pub async fn fetch_episode_by_id(&self, id: &str, parameters: &Value) -> Result { + self.get(&format!("episodes/{}", id), parameters).await + } + + /// Calls [`POST /episodes`](https://www.listennotes.com/api/docs/#post-api-v2-episodes) with supplied parameters. + pub async fn batch_fetch_episodes(&self, parameters: &Value) -> Result { + self.post("episodes", parameters).await } async fn get(&self, endpoint: &str, parameters: &Value) -> Result { - Ok(self + let request = self .client .get(format!("{}/{}", self.api.url(), endpoint)) - .query(parameters) - .send() - .await? - .json() - .await?) + .query(parameters); + + Ok(self.request(request).await?) } async fn post(&self, endpoint: &str, parameters: &Value) -> Result { - Ok(self + let request = self .client .post(format!("{}/{}", self.api.url(), endpoint)) .header("Content-Type", "application/x-www-form-urlencoded") - .body(serde_json::to_string(¶meters)?) // TODO: switch to URL encoding - .send() - .await? - .json() - .await?) + .body(Self::urlencoded_from_json(parameters)); + + Ok(self.request(request).await?) } -} -trait AddField { - fn with(&self, key: &str, value: &str) -> Self; + async fn request(&self, request: RequestBuilder) -> Result { + Ok(if let Api::Production(key) = self.api { + request.header("X-ListenAPI-Key", key) + } else { + request + } + .send() + .await? + .json() + .await?) + } + + fn urlencoded_from_json(json: &Value) -> String { + if let Some(v) = json.as_object() { + v.iter() + .map(|(key, value)| { + format!( + "{}={}", + key, + match value { + Value::String(s) => s.to_owned(), // serde_json String(_) formatter includes the quotations marks, this doesn't + _ => format!("{}", value), + } + ) + }) + .collect::>() + .join("&") + } else { + String::new() + } + } } -impl AddField for Value { - fn with(&self, key: &str, value: &str) -> Self { - let mut p = self.clone(); - p[key] = json!(value); - p +#[cfg(test)] +mod tests { + use serde_json::json; + #[test] + fn urlencoded_from_json() { + assert_eq!( + super::Client::urlencoded_from_json(&json!({ + "a": 1, + "b": true, + "c": "test_string" + })), + "a=1&b=true&c=test_string" + ); } } diff --git a/src/error.rs b/src/error.rs index 6436514..ba977aa 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,9 @@ +/// Error for API calls from [`Client`](super::Client). #[derive(Debug)] pub enum Error { + /// Error from http client. Reqwest(reqwest::Error), + /// Error from JSON creation/processing. Json(serde_json::Error), } diff --git a/src/lib.rs b/src/lib.rs index f858d5a..eec4a4d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,40 @@ +//! Official library for accessing the [Listen API](https://www.listennotes.com/api) by [Listen Notes](https://www.listennotes.com). +//! +//! # Quick Start Example +//! +//! ``` +//! use serde_json::json; +//! +//! #[tokio::main] +//! async fn main() { +//! // Api Key (None => Test API, Some(key) => Production API) +//! let api_key = None; +//! +//! // Create client +//! let client = podcast_api::Client::new(reqwest::Client::new(), api_key); +//! +//! // Call API +//! match client +//! .typeahead(&json!({ +//! "q": "startup", +//! "show_podcasts": 1 +//! })) +//! .await +//! { +//! Ok(response) => { +//! println!("Successfully called \"typeahead\" endpoint."); +//! println!("Response Body:"); +//! println!("{:?}", response); +//! } +//! Err(err) => { +//! println!("Error calling \"typeahead\" endpoint:"); +//! println!("{:?},", err); +//! } +//! }; +//! } +//! ``` +#![deny(missing_docs)] + mod api; mod client; mod error; @@ -6,4 +43,5 @@ use api::Api; pub use client::Client; pub use error::Error; +/// Result for API calls from [`Client`] pub type Result = std::result::Result; diff --git a/tests/client_tests.rs b/tests/client_tests.rs new file mode 100644 index 0000000..4a41cf1 --- /dev/null +++ b/tests/client_tests.rs @@ -0,0 +1,85 @@ +macro_rules! b { + ($e:expr) => { + tokio_test::block_on($e) + }; +} + +mod mock { + use serde_json::json; + + fn client<'a>() -> podcast_api::Client<'a> { + podcast_api::Client::new(reqwest::Client::new(), None) + } + + #[test] + fn search() { + let response = b!(client().search(&json!({ + "q": "dummy", + "sort_by_date": 1 + }))) + .unwrap(); + assert!(response.is_object()); + assert!(response["results"].as_array().unwrap().len() > 0); + } + + #[test] + fn typeahead() { + let response = b!(client().typeahead(&json!({ + "q": "dummy", + "show_podcasts": 1 + }))) + .unwrap(); + assert!(response.is_object()); + assert!(response["terms"].as_array().unwrap().len() > 0); + } + + #[test] + fn fetch_best_podcasts() { + let response = b!(client().fetch_best_podcasts(&json!({ + "genre_id": 23, + }))) + .unwrap(); + assert!(response.is_object()); + assert!(response["total"].as_i64().unwrap() > 0); + } + + #[test] + fn fetch_podcast_by_id() { + let response = b!(client().fetch_podcast_by_id("dummy_id", &json!({}))).unwrap(); + assert!(response.is_object()); + assert!(response["episodes"].as_array().unwrap().len() > 0); + } + + #[test] + fn batch_fetch_podcasts() { + let response = b!(client().batch_fetch_podcasts(&json!({ + "ids": "996,777,888,1000" + }))) + .unwrap(); + assert!(response.is_object()); + assert!(response["podcasts"].as_array().unwrap().len() > 0); + } + + #[test] + fn fetch_episode_by_id() { + let response = b!(client().fetch_episode_by_id("dummy_id", &json!({}))).unwrap(); + assert!(response.is_object()); + assert!( + response["podcast"].as_object().unwrap()["rss"] + .as_str() + .unwrap() + .len() + > 0 + ); + } + + #[test] + fn batch_fetch_episodes() { + let response = b!(client().batch_fetch_episodes(&json!({ + "ids": "996,777,888,1000" + }))) + .unwrap(); + assert!(response.is_object()); + assert!(response["episodes"].as_array().unwrap().len() > 0); + } +}