From 5299ea0c9541ea845c49cecf652ce9a1dfd0c656 Mon Sep 17 00:00:00 2001 From: Cameron Fyfe Date: Sat, 15 May 2021 14:58:04 -0600 Subject: [PATCH 01/11] Hold API key as &str instead of String --- src/api.rs | 9 ++++++--- src/client.rs | 8 ++++---- 2 files changed, 10 insertions(+), 7 deletions(-) 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..a97b281 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,13 +1,13 @@ use super::{Api, Result}; use serde_json::{json, Value}; -pub struct Client { +pub struct Client<'a> { client: reqwest::Client, - api: Api, + api: Api<'a>, } -impl Client { - pub fn new(client: reqwest::Client, id: Option) -> Client { +impl Client<'_> { + pub fn new<'a>(client: reqwest::Client, id: Option<&'a str>) -> Client<'a> { Client { client, api: if let Some(id) = id { From ad961fb12237017e781f262dc3459cc5d0ee98cb Mon Sep 17 00:00:00 2001 From: Cameron Fyfe Date: Sat, 15 May 2021 14:59:45 -0600 Subject: [PATCH 02/11] Complete first 7 endpoints --- src/client.rs | 52 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/src/client.rs b/src/client.rs index a97b281..928bdd8 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,5 +1,6 @@ use super::{Api, Result}; -use serde_json::{json, Value}; +use reqwest::RequestBuilder; +use serde_json::Value; pub struct Client<'a> { client: reqwest::Client, @@ -26,40 +27,51 @@ impl Client<'_> { self.get("typeahead", parameters).await } - pub async fn episode_by_id(&self, id: &str, parameters: &Value) -> Result { - self.get(&format!("episodes/{}", id), parameters).await + pub async fn best_podcasts(&self, parameters: &Value) -> Result { + self.get("best_podcasts", parameters).await } - pub async fn episodes(&self, ids: &[&str], parameters: &Value) -> Result { - self.post("episodes", ¶meters.with("ids", &ids.join(",").as_str())) - .await + pub async fn podcast(&self, id: &str, parameters: &Value) -> Result { + self.get(&format!("podcasts/{}", id), parameters).await } - pub async fn genres(&self, parameters: &Value) -> Result { - self.get("genres", parameters).await + pub async fn podcasts(&self, parameters: &Value) -> Result { + self.post("podcasts", parameters).await } + pub async fn episode(&self, id: &str, parameters: &Value) -> Result { + self.get(&format!("episodes/{}", id), parameters).await + pub async fn 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(serde_json::to_string(¶meters)?); // TODO: switch to URL encoding + + Ok(self.request(request).await?) + } + + 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?) } } From 0aec83907e1e925ddb5f15fe024354f4de96a870 Mon Sep 17 00:00:00 2001 From: Cameron Fyfe Date: Sat, 15 May 2021 15:03:01 -0600 Subject: [PATCH 03/11] Unit tests for first 7 endpoints --- Cargo.toml | 1 + tests/client_tests.rs | 85 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 tests/client_tests.rs 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/tests/client_tests.rs b/tests/client_tests.rs new file mode 100644 index 0000000..549b09d --- /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 best_podcasts() { + let response = b!(client().best_podcasts(&json!({ + "genre_id": 23, + }))) + .unwrap(); + assert!(response.is_object()); + assert!(response["total"].as_i64().unwrap() > 0); + } + + #[test] + fn podcast() { + let response = b!(client().podcast("dummy_id", &json!({}))).unwrap(); + assert!(response.is_object()); + assert!(response["episodes"].as_array().unwrap().len() > 0); + } + + #[test] + fn podcasts() { + let response = b!(client().podcasts(&json!({ + "ids": "996,777,888,1000" + }))) + .unwrap(); + assert!(response.is_object()); + assert!(response["podcasts"].as_array().unwrap().len() > 0); + } + + #[test] + fn episode() { + let response = b!(client().episode("dummy_id", &json!({}))).unwrap(); + assert!(response.is_object()); + assert!( + response["podcast"].as_object().unwrap()["rss"] + .as_str() + .unwrap() + .len() + > 0 + ); + } + + #[test] + fn episodes() { + let response = b!(client().episodes(&json!({ + "ids": "996,777,888,1000" + }))) + .unwrap(); + assert!(response.is_object()); + assert!(response["episodes"].as_array().unwrap().len() > 0); + } +} From f63c418258303b1a7670fad0fdfaf7e469b7dc2f Mon Sep 17 00:00:00 2001 From: Cameron Fyfe Date: Sat, 15 May 2021 15:20:22 -0600 Subject: [PATCH 04/11] Add crate docs --- src/client.rs | 36 ++++++++++++++++++++++++------------ src/error.rs | 3 +++ src/lib.rs | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 12 deletions(-) diff --git a/src/client.rs b/src/client.rs index 928bdd8..a44723f 100644 --- a/src/client.rs +++ b/src/client.rs @@ -2,12 +2,25 @@ use super::{Api, Result}; use reqwest::RequestBuilder; use serde_json::Value; +/// Client for accessing Listen Notes API. pub struct Client<'a> { + /// HTTP client. client: reqwest::Client, + /// API context. api: Api<'a>, } 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<'a>(client: reqwest::Client, id: Option<&'a str>) -> Client<'a> { Client { client, @@ -19,30 +32,41 @@ 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 } + /// Calls [`GET /best_podcasts`](https://www.listennotes.com/api/docs/#get-api-v2-best_podcasts) with supplied parameters. pub async fn 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 podcast(&self, id: &str, parameters: &Value) -> Result { self.get(&format!("podcasts/{}", id), parameters).await } + /// Calls [`POST /podcasts`](https://www.listennotes.com/api/docs/#post-api-v2-podcasts) with supplied parameters. pub async fn podcasts(&self, parameters: &Value) -> Result { self.post("podcasts", parameters).await } + /// Calls [`GET /episodes/{id}`](https://www.listennotes.com/api/docs/#get-api-v2-episodes-id) with supplied parameters. pub async fn episode(&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 episodes(&self, parameters: &Value) -> Result { self.post("episodes", parameters).await + } + async fn get(&self, endpoint: &str, parameters: &Value) -> Result { let request = self .client @@ -74,15 +98,3 @@ impl Client<'_> { .await?) } } - -trait AddField { - fn with(&self, key: &str, value: &str) -> Self; -} - -impl AddField for Value { - fn with(&self, key: &str, value: &str) -> Self { - let mut p = self.clone(); - p[key] = json!(value); - p - } -} 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..ea647e1 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; From 0600308d794731a344fe138ffd7305d5a28fbbfc Mon Sep 17 00:00:00 2001 From: Cameron Fyfe Date: Sat, 15 May 2021 15:23:30 -0600 Subject: [PATCH 05/11] rustfmt --- src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index ea647e1..eec4a4d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ //! Official library for accessing the [Listen API](https://www.listennotes.com/api) by [Listen Notes](https://www.listennotes.com). -//! +//! //! # Quick Start Example //! //! ``` From fc1be97e6e56ba699fd616e79696ef6e23616776 Mon Sep 17 00:00:00 2001 From: Cameron Fyfe Date: Sat, 15 May 2021 15:25:54 -0600 Subject: [PATCH 06/11] clippy - switch to implicit lifetime --- src/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.rs b/src/client.rs index a44723f..64f6730 100644 --- a/src/client.rs +++ b/src/client.rs @@ -21,7 +21,7 @@ impl Client<'_> { /// ``` /// let client = podcast_api::Client::new(reqwest::Client::new(), None); /// ``` - pub fn new<'a>(client: reqwest::Client, id: Option<&'a str>) -> Client<'a> { + pub fn new(client: reqwest::Client, id: Option<&str>) -> Client { Client { client, api: if let Some(id) = id { From 23a011e5c710552b410fc058c5698637bc81178a Mon Sep 17 00:00:00 2001 From: Cameron Fyfe Date: Sat, 15 May 2021 15:31:41 -0600 Subject: [PATCH 07/11] Add brief instructions to readme --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 4e43cd2..2cdac8b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,20 @@ # podcast-api-rust Rust library for the Listen Notes Podcast API + +## Check + - formatting: `cargo fmt --check` + - valid code: `cargo check` + - linting: `cargo clippy` + +## 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 From 94fa566328f197b35dcc248a06ecf6f6488a152a Mon Sep 17 00:00:00 2001 From: Cameron Fyfe Date: Sat, 15 May 2021 15:33:20 -0600 Subject: [PATCH 08/11] Add doc command --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 2cdac8b..c07bef4 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ Rust library for the Listen Notes Podcast API - valid code: `cargo check` - linting: `cargo clippy` +## Open Docs +`cargo doc --open` + ## Build `cargo build` From ba2c5f14e4280ae7cf9063810377bdb572165d25 Mon Sep 17 00:00:00 2001 From: Cameron Fyfe Date: Sat, 15 May 2021 16:58:08 -0600 Subject: [PATCH 09/11] typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c07bef4..9878bc1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Rust library for the Listen Notes Podcast API ## Check - - formatting: `cargo fmt --check` + - formatting: `cargo fmt -- --check` - valid code: `cargo check` - linting: `cargo clippy` From 863c4a607952dd8ff0589587540eea6589591c21 Mon Sep 17 00:00:00 2001 From: Cameron Fyfe Date: Sat, 15 May 2021 17:21:44 -0600 Subject: [PATCH 10/11] Switch from json to urlencoded for POST body --- src/client.rs | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/client.rs b/src/client.rs index 64f6730..57b055e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -81,7 +81,7 @@ impl Client<'_> { .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 + .body(Self::urlencoded_from_json(parameters)); Ok(self.request(request).await?) } @@ -97,4 +97,40 @@ impl Client<'_> { .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() + } + } +} + +#[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" + ); + } } From 98bc939cf21b1110e3f2f877a13609dfaea61a7c Mon Sep 17 00:00:00 2001 From: Cameron Fyfe Date: Sat, 15 May 2021 18:27:33 -0600 Subject: [PATCH 11/11] Switch to standard library naming scheme --- src/client.rs | 10 +++++----- tests/client_tests.rs | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/client.rs b/src/client.rs index 57b055e..2b3c47e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -43,27 +43,27 @@ impl Client<'_> { } /// Calls [`GET /best_podcasts`](https://www.listennotes.com/api/docs/#get-api-v2-best_podcasts) with supplied parameters. - pub async fn best_podcasts(&self, parameters: &Value) -> Result { + 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 podcast(&self, id: &str, parameters: &Value) -> Result { + pub async fn fetch_podcast_by_id(&self, id: &str, parameters: &Value) -> Result { self.get(&format!("podcasts/{}", id), parameters).await } /// Calls [`POST /podcasts`](https://www.listennotes.com/api/docs/#post-api-v2-podcasts) with supplied parameters. - pub async fn podcasts(&self, parameters: &Value) -> Result { + pub async fn batch_fetch_podcasts(&self, parameters: &Value) -> Result { self.post("podcasts", parameters).await } /// Calls [`GET /episodes/{id}`](https://www.listennotes.com/api/docs/#get-api-v2-episodes-id) with supplied parameters. - pub async fn episode(&self, id: &str, parameters: &Value) -> Result { + 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 episodes(&self, parameters: &Value) -> Result { + pub async fn batch_fetch_episodes(&self, parameters: &Value) -> Result { self.post("episodes", parameters).await } diff --git a/tests/client_tests.rs b/tests/client_tests.rs index 549b09d..4a41cf1 100644 --- a/tests/client_tests.rs +++ b/tests/client_tests.rs @@ -34,8 +34,8 @@ mod mock { } #[test] - fn best_podcasts() { - let response = b!(client().best_podcasts(&json!({ + fn fetch_best_podcasts() { + let response = b!(client().fetch_best_podcasts(&json!({ "genre_id": 23, }))) .unwrap(); @@ -44,15 +44,15 @@ mod mock { } #[test] - fn podcast() { - let response = b!(client().podcast("dummy_id", &json!({}))).unwrap(); + 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 podcasts() { - let response = b!(client().podcasts(&json!({ + fn batch_fetch_podcasts() { + let response = b!(client().batch_fetch_podcasts(&json!({ "ids": "996,777,888,1000" }))) .unwrap(); @@ -61,8 +61,8 @@ mod mock { } #[test] - fn episode() { - let response = b!(client().episode("dummy_id", &json!({}))).unwrap(); + 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"] @@ -74,8 +74,8 @@ mod mock { } #[test] - fn episodes() { - let response = b!(client().episodes(&json!({ + fn batch_fetch_episodes() { + let response = b!(client().batch_fetch_episodes(&json!({ "ids": "996,777,888,1000" }))) .unwrap();