diff --git a/Cargo.toml b/Cargo.toml index ce8af18..4619d60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,8 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +form_urlencoded = "1" +http = "0.2" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } diff --git a/examples/sample/src/main.rs b/examples/sample/src/main.rs index f18745d..604561a 100644 --- a/examples/sample/src/main.rs +++ b/examples/sample/src/main.rs @@ -6,7 +6,7 @@ async fn main() { let api_key = None; // Create client - let client = podcast_api::Client::new(reqwest::Client::new(), api_key); + let client = podcast_api::Client::new(api_key); // Call API match client @@ -18,8 +18,12 @@ async fn main() { { Ok(response) => { println!("Successfully called \"typeahead\" endpoint."); - println!("Response Body:"); - println!("{:?}", response); + if let Ok(body) = response.json().await { + println!("Response Body:"); + println!("{:?}", body); + } else { + println!("Response body JSON data parsing error.") + } } Err(err) => { println!("Error calling \"typeahead\" endpoint:"); diff --git a/src/client.rs b/src/client.rs index 2b3c47e..986bf26 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,6 +1,9 @@ use super::{Api, Result}; use reqwest::RequestBuilder; use serde_json::Value; +use std::time::Duration; + +static DEFAULT_USER_AGENT: &str = "api-podcast-rust"; /// Client for accessing Listen Notes API. pub struct Client<'a> { @@ -8,20 +11,60 @@ pub struct Client<'a> { client: reqwest::Client, /// API context. api: Api<'a>, + /// User Agent Header for API calls. + user_agent: &'a str, +} + +#[derive(Debug)] +/// Response and request context for API call. +pub struct Response { + /// HTTP response. + pub response: reqwest::Response, + /// HTTP request that resulted in this response. + pub request: reqwest::Request, +} + +impl Response { + /// Get JSON data object from [`reqwest::Response`]. + pub async fn json(self) -> Result { + Ok(self.response.json().await?) + } } impl Client<'_> { /// Creates new Listen API Client. /// + /// Uses default HTTP client with 30 second timeouts. + /// /// To access production API: /// ``` - /// let client = podcast_api::Client::new(reqwest::Client::new(), Some("YOUR-API-KEY")); + /// let client = podcast_api::Client::new(Some("YOUR-API-KEY")); /// ``` /// To access mock API: /// ``` - /// let client = podcast_api::Client::new(reqwest::Client::new(), None); + /// let client = podcast_api::Client::new(None); /// ``` - pub fn new(client: reqwest::Client, id: Option<&str>) -> Client { + pub fn new(id: Option<&str>) -> Client { + Client { + client: reqwest::ClientBuilder::new() + .timeout(Duration::from_secs(30)) + .build() + .expect("Client::new()"), + api: if let Some(id) = id { + Api::Production(id) + } else { + Api::Mock + }, + user_agent: DEFAULT_USER_AGENT, + } + } + + /// Creates new Listen API Client with user provided HTTP Client. + pub fn new_custom<'a>( + client: reqwest::Client, + id: Option<&'a str>, + user_agent: Option<&'a str>, + ) -> Client<'a> { Client { client, api: if let Some(id) = id { @@ -29,45 +72,50 @@ impl Client<'_> { } else { Api::Mock }, + user_agent: if let Some(user_agent) = user_agent { + user_agent + } else { + DEFAULT_USER_AGENT + }, } } /// Calls [`GET /search`](https://www.listennotes.com/api/docs/#get-api-v2-search) with supplied parameters. - pub async fn search(&self, parameters: &Value) -> Result { + 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 { + 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 fetch_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 fetch_podcast_by_id(&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 batch_fetch_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 fetch_episode_by_id(&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 batch_fetch_episodes(&self, parameters: &Value) -> Result { + pub async fn batch_fetch_episodes(&self, parameters: &Value) -> Result { self.post("episodes", parameters).await } - async fn get(&self, endpoint: &str, parameters: &Value) -> Result { + async fn get(&self, endpoint: &str, parameters: &Value) -> Result { let request = self .client .get(format!("{}/{}", self.api.url(), endpoint)) @@ -76,7 +124,7 @@ impl Client<'_> { Ok(self.request(request).await?) } - async fn post(&self, endpoint: &str, parameters: &Value) -> Result { + async fn post(&self, endpoint: &str, parameters: &Value) -> Result { let request = self .client .post(format!("{}/{}", self.api.url(), endpoint)) @@ -86,16 +134,19 @@ impl Client<'_> { Ok(self.request(request).await?) } - async fn request(&self, request: RequestBuilder) -> Result { - Ok(if let Api::Production(key) = self.api { + async fn request(&self, request: RequestBuilder) -> Result { + let request = if let Api::Production(key) = self.api { request.header("X-ListenAPI-Key", key) } else { request } - .send() - .await? - .json() - .await?) + .header("User-Agent", self.user_agent) + .build()?; + + Ok(Response { + response: self.client.execute(request.try_clone().expect("Error can remain unhandled because we're not using streams, which are the try_clone fail condition")).await?, + request, + }) } fn urlencoded_from_json(json: &Value) -> String { diff --git a/src/lib.rs b/src/lib.rs index eec4a4d..12d761f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,7 @@ //! let api_key = None; //! //! // Create client -//! let client = podcast_api::Client::new(reqwest::Client::new(), api_key); +//! let client = podcast_api::Client::new(api_key); //! //! // Call API //! match client @@ -42,6 +42,7 @@ mod error; use api::Api; pub use client::Client; +pub use client::Response; 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 index 4a41cf1..3ef8652 100644 --- a/tests/client_tests.rs +++ b/tests/client_tests.rs @@ -6,80 +6,198 @@ macro_rules! b { mod mock { use serde_json::json; + use std::borrow::Cow; fn client<'a>() -> podcast_api::Client<'a> { - podcast_api::Client::new(reqwest::Client::new(), None) + podcast_api::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); + b!(async { + let response = client() + .search(&json!({ + "q": "dummy", + "sort_by_date": 1 + })) + .await + .unwrap(); + // Request + assert_eq!(response.request.method(), http::Method::GET); + assert_eq!(response.request.url().path(), "/api/v2/search"); + let mut p = response.request.url().query_pairs(); + assert_eq!(p.count(), 2); + assert_eq!(p.next(), Some((Cow::Borrowed("q"), Cow::Borrowed("dummy")))); + assert_eq!( + p.next(), + Some((Cow::Borrowed("sort_by_date"), Cow::Borrowed("1"))) + ); + // Response + let body = response.json().await.unwrap(); + assert!(body.is_object()); + assert!(body["results"].as_array().unwrap().len() > 0); + }); + } + + #[test] + fn search_with_authentication_error() { + b!(async { + let response = podcast_api::Client::new(Some("wrong_key")) + .search(&json!({ + "q": "dummy", + "sort_by_date": 1 + })) + .await + .unwrap(); + assert_eq!(response.response.status(), http::StatusCode::UNAUTHORIZED); + }); } #[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); + b!(async { + let response = client() + .typeahead(&json!({ + "q": "dummy", + "show_podcasts": 1 + })) + .await + .unwrap(); + // Request + assert_eq!(response.request.method(), http::Method::GET); + assert_eq!(response.request.url().path(), "/api/v2/typeahead"); + let mut p = response.request.url().query_pairs(); + assert_eq!(p.count(), 2); + assert_eq!(p.next(), Some((Cow::Borrowed("q"), Cow::Borrowed("dummy")))); + assert_eq!( + p.next(), + Some((Cow::Borrowed("show_podcasts"), Cow::Borrowed("1"))) + ); + // Response + let body = response.json().await.unwrap(); + assert!(body.is_object()); + assert!(body["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); + b!(async { + let response = client() + .fetch_best_podcasts(&json!({ + "genre_id": 23 + })) + .await + .unwrap(); + // Request + assert_eq!(response.request.method(), http::Method::GET); + assert_eq!(response.request.url().path(), "/api/v2/best_podcasts"); + let mut p = response.request.url().query_pairs(); + assert_eq!(p.count(), 1); + assert_eq!( + p.next(), + Some((Cow::Borrowed("genre_id"), Cow::Borrowed("23"))) + ); + // Response + let body = response.json().await.unwrap(); + assert!(body.is_object()); + assert!(body["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); + b!(async { + let response = client() + .fetch_podcast_by_id("dummy_id", &json!({})) + .await + .unwrap(); + // Request + assert_eq!(response.request.method(), http::Method::GET); + assert_eq!(response.request.url().path(), "/api/v2/podcasts/dummy_id"); + let p = response.request.url().query_pairs(); + assert_eq!(p.count(), 0); + // Response + let body = response.json().await.unwrap(); + assert!(body.is_object()); + assert!(body["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); + b!(async { + let response = client() + .batch_fetch_podcasts(&json!({ + "ids": "996,777,888,1000" + })) + .await + .unwrap(); + // Request + assert_eq!(response.request.method(), http::Method::POST); + assert_eq!(response.request.url().path(), "/api/v2/podcasts"); + let mut p = + form_urlencoded::parse(response.request.body().unwrap().as_bytes().unwrap()); + assert_eq!(p.count(), 1); + assert_eq!( + p.next(), + Some((Cow::Borrowed("ids"), Cow::Borrowed("996,777,888,1000"))) + ); + // Response + let body = response.json().await.unwrap(); + assert!(body.is_object()); + assert!(body["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 - ); + b!(async { + let response = client() + .fetch_episode_by_id("dummy_id", &json!({})) + .await + .unwrap(); + // Request + assert_eq!(response.request.method(), http::Method::GET); + assert_eq!(response.request.url().path(), "/api/v2/episodes/dummy_id"); + let p = response.request.url().query_pairs(); + assert_eq!(p.count(), 0); + // Response + let body = response.json().await.unwrap(); + assert!(body.is_object()); + assert!( + body["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); + b!(async { + let response = client() + .batch_fetch_episodes(&json!({ + "ids": "996,777,888,1000" + })) + .await + .unwrap(); + // Request + assert_eq!(response.request.method(), http::Method::POST); + assert_eq!(response.request.url().path(), "/api/v2/episodes"); + let mut p = + form_urlencoded::parse(response.request.body().unwrap().as_bytes().unwrap()); + assert_eq!(p.count(), 1); + assert_eq!( + p.next(), + Some((Cow::Borrowed("ids"), Cow::Borrowed("996,777,888,1000"))) + ); + // Response + let body = response.json().await.unwrap(); + assert!(body.is_object()); + assert!(body["episodes"].as_array().unwrap().len() > 0); + }); } }