Skip to content

Commit b421d57

Browse files
authored
Merge pull request #2 from ListenNotes/cameron-develop
Finish first 7 endpoints + unit tests + crate docs
2 parents 144e1b9 + 98bc939 commit b421d57

File tree

7 files changed

+246
-35
lines changed

7 files changed

+246
-35
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ edition = "2018"
1010
serde = { version = "1", features = ["derive"] }
1111
serde_json = "1"
1212
tokio = { version = "1", features = ["full"] }
13+
tokio-test = "0.4"
1314
reqwest = { version = "0.11", features = ["json"] }

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,23 @@
11
# podcast-api-rust
22
Rust library for the Listen Notes Podcast API
3+
4+
## Check
5+
- formatting: `cargo fmt -- --check`
6+
- valid code: `cargo check`
7+
- linting: `cargo clippy`
8+
9+
## Open Docs
10+
`cargo doc --open`
11+
12+
## Build
13+
`cargo build`
14+
15+
## Test
16+
`cargo test`
17+
18+
## Usage
19+
Add this to your `Cargo.toml`:
20+
```toml
21+
[dependencies]
22+
podcast-api = "0.1"
23+
```

src/api.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
pub enum Api {
2-
Production(String),
1+
/// API url and key context.
2+
pub enum Api<'a> {
3+
/// API context for Listen Notes production API.
4+
Production(&'a str),
5+
/// API context for Listen Notes mock API for testing.
36
Mock,
47
}
58

6-
impl Api {
9+
impl Api<'_> {
710
pub fn url(&self) -> &str {
811
match &self {
912
Api::Production(_) => "https://listen-api.listennotes.com/api/v2",

src/client.rs

Lines changed: 92 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
11
use super::{Api, Result};
2-
use serde_json::{json, Value};
2+
use reqwest::RequestBuilder;
3+
use serde_json::Value;
34

4-
pub struct Client {
5+
/// Client for accessing Listen Notes API.
6+
pub struct Client<'a> {
7+
/// HTTP client.
58
client: reqwest::Client,
6-
api: Api,
9+
/// API context.
10+
api: Api<'a>,
711
}
812

9-
impl Client {
10-
pub fn new(client: reqwest::Client, id: Option<String>) -> Client {
13+
impl Client<'_> {
14+
/// Creates new Listen API Client.
15+
///
16+
/// To access production API:
17+
/// ```
18+
/// let client = podcast_api::Client::new(reqwest::Client::new(), Some("YOUR-API-KEY"));
19+
/// ```
20+
/// To access mock API:
21+
/// ```
22+
/// let client = podcast_api::Client::new(reqwest::Client::new(), None);
23+
/// ```
24+
pub fn new(client: reqwest::Client, id: Option<&str>) -> Client {
1125
Client {
1226
client,
1327
api: if let Some(id) = id {
@@ -18,59 +32,105 @@ impl Client {
1832
}
1933
}
2034

35+
/// Calls [`GET /search`](https://www.listennotes.com/api/docs/#get-api-v2-search) with supplied parameters.
2136
pub async fn search(&self, parameters: &Value) -> Result<Value> {
2237
self.get("search", parameters).await
2338
}
2439

40+
/// Calls [`GET /typeahead`](https://www.listennotes.com/api/docs/#get-api-v2-typeahead) with supplied parameters.
2541
pub async fn typeahead(&self, parameters: &Value) -> Result<Value> {
2642
self.get("typeahead", parameters).await
2743
}
2844

29-
pub async fn episode_by_id(&self, id: &str, parameters: &Value) -> Result<Value> {
30-
self.get(&format!("episodes/{}", id), parameters).await
45+
/// Calls [`GET /best_podcasts`](https://www.listennotes.com/api/docs/#get-api-v2-best_podcasts) with supplied parameters.
46+
pub async fn fetch_best_podcasts(&self, parameters: &Value) -> Result<Value> {
47+
self.get("best_podcasts", parameters).await
48+
}
49+
50+
/// Calls [`GET /podcasts/{id}`](https://www.listennotes.com/api/docs/#get-api-v2-podcasts-id) with supplied parameters.
51+
pub async fn fetch_podcast_by_id(&self, id: &str, parameters: &Value) -> Result<Value> {
52+
self.get(&format!("podcasts/{}", id), parameters).await
3153
}
3254

33-
pub async fn episodes(&self, ids: &[&str], parameters: &Value) -> Result<Value> {
34-
self.post("episodes", &parameters.with("ids", &ids.join(",").as_str()))
35-
.await
55+
/// Calls [`POST /podcasts`](https://www.listennotes.com/api/docs/#post-api-v2-podcasts) with supplied parameters.
56+
pub async fn batch_fetch_podcasts(&self, parameters: &Value) -> Result<Value> {
57+
self.post("podcasts", parameters).await
3658
}
3759

38-
pub async fn genres(&self, parameters: &Value) -> Result<Value> {
39-
self.get("genres", parameters).await
60+
/// Calls [`GET /episodes/{id}`](https://www.listennotes.com/api/docs/#get-api-v2-episodes-id) with supplied parameters.
61+
pub async fn fetch_episode_by_id(&self, id: &str, parameters: &Value) -> Result<Value> {
62+
self.get(&format!("episodes/{}", id), parameters).await
63+
}
64+
65+
/// Calls [`POST /episodes`](https://www.listennotes.com/api/docs/#post-api-v2-episodes) with supplied parameters.
66+
pub async fn batch_fetch_episodes(&self, parameters: &Value) -> Result<Value> {
67+
self.post("episodes", parameters).await
4068
}
4169

4270
async fn get(&self, endpoint: &str, parameters: &Value) -> Result<Value> {
43-
Ok(self
71+
let request = self
4472
.client
4573
.get(format!("{}/{}", self.api.url(), endpoint))
46-
.query(parameters)
47-
.send()
48-
.await?
49-
.json()
50-
.await?)
74+
.query(parameters);
75+
76+
Ok(self.request(request).await?)
5177
}
5278

5379
async fn post(&self, endpoint: &str, parameters: &Value) -> Result<Value> {
54-
Ok(self
80+
let request = self
5581
.client
5682
.post(format!("{}/{}", self.api.url(), endpoint))
5783
.header("Content-Type", "application/x-www-form-urlencoded")
58-
.body(serde_json::to_string(&parameters)?) // TODO: switch to URL encoding
59-
.send()
60-
.await?
61-
.json()
62-
.await?)
84+
.body(Self::urlencoded_from_json(parameters));
85+
86+
Ok(self.request(request).await?)
6387
}
64-
}
6588

66-
trait AddField {
67-
fn with(&self, key: &str, value: &str) -> Self;
89+
async fn request(&self, request: RequestBuilder) -> Result<Value> {
90+
Ok(if let Api::Production(key) = self.api {
91+
request.header("X-ListenAPI-Key", key)
92+
} else {
93+
request
94+
}
95+
.send()
96+
.await?
97+
.json()
98+
.await?)
99+
}
100+
101+
fn urlencoded_from_json(json: &Value) -> String {
102+
if let Some(v) = json.as_object() {
103+
v.iter()
104+
.map(|(key, value)| {
105+
format!(
106+
"{}={}",
107+
key,
108+
match value {
109+
Value::String(s) => s.to_owned(), // serde_json String(_) formatter includes the quotations marks, this doesn't
110+
_ => format!("{}", value),
111+
}
112+
)
113+
})
114+
.collect::<Vec<String>>()
115+
.join("&")
116+
} else {
117+
String::new()
118+
}
119+
}
68120
}
69121

70-
impl AddField for Value {
71-
fn with(&self, key: &str, value: &str) -> Self {
72-
let mut p = self.clone();
73-
p[key] = json!(value);
74-
p
122+
#[cfg(test)]
123+
mod tests {
124+
use serde_json::json;
125+
#[test]
126+
fn urlencoded_from_json() {
127+
assert_eq!(
128+
super::Client::urlencoded_from_json(&json!({
129+
"a": 1,
130+
"b": true,
131+
"c": "test_string"
132+
})),
133+
"a=1&b=true&c=test_string"
134+
);
75135
}
76136
}

src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
/// Error for API calls from [`Client`](super::Client).
12
#[derive(Debug)]
23
pub enum Error {
4+
/// Error from http client.
35
Reqwest(reqwest::Error),
6+
/// Error from JSON creation/processing.
47
Json(serde_json::Error),
58
}
69

src/lib.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,40 @@
1+
//! Official library for accessing the [Listen API](https://www.listennotes.com/api) by [Listen Notes](https://www.listennotes.com).
2+
//!
3+
//! # Quick Start Example
4+
//!
5+
//! ```
6+
//! use serde_json::json;
7+
//!
8+
//! #[tokio::main]
9+
//! async fn main() {
10+
//! // Api Key (None => Test API, Some(key) => Production API)
11+
//! let api_key = None;
12+
//!
13+
//! // Create client
14+
//! let client = podcast_api::Client::new(reqwest::Client::new(), api_key);
15+
//!
16+
//! // Call API
17+
//! match client
18+
//! .typeahead(&json!({
19+
//! "q": "startup",
20+
//! "show_podcasts": 1
21+
//! }))
22+
//! .await
23+
//! {
24+
//! Ok(response) => {
25+
//! println!("Successfully called \"typeahead\" endpoint.");
26+
//! println!("Response Body:");
27+
//! println!("{:?}", response);
28+
//! }
29+
//! Err(err) => {
30+
//! println!("Error calling \"typeahead\" endpoint:");
31+
//! println!("{:?},", err);
32+
//! }
33+
//! };
34+
//! }
35+
//! ```
36+
#![deny(missing_docs)]
37+
138
mod api;
239
mod client;
340
mod error;
@@ -6,4 +43,5 @@ use api::Api;
643

744
pub use client::Client;
845
pub use error::Error;
46+
/// Result for API calls from [`Client`]
947
pub type Result<T> = std::result::Result<T, error::Error>;

tests/client_tests.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
macro_rules! b {
2+
($e:expr) => {
3+
tokio_test::block_on($e)
4+
};
5+
}
6+
7+
mod mock {
8+
use serde_json::json;
9+
10+
fn client<'a>() -> podcast_api::Client<'a> {
11+
podcast_api::Client::new(reqwest::Client::new(), None)
12+
}
13+
14+
#[test]
15+
fn search() {
16+
let response = b!(client().search(&json!({
17+
"q": "dummy",
18+
"sort_by_date": 1
19+
})))
20+
.unwrap();
21+
assert!(response.is_object());
22+
assert!(response["results"].as_array().unwrap().len() > 0);
23+
}
24+
25+
#[test]
26+
fn typeahead() {
27+
let response = b!(client().typeahead(&json!({
28+
"q": "dummy",
29+
"show_podcasts": 1
30+
})))
31+
.unwrap();
32+
assert!(response.is_object());
33+
assert!(response["terms"].as_array().unwrap().len() > 0);
34+
}
35+
36+
#[test]
37+
fn fetch_best_podcasts() {
38+
let response = b!(client().fetch_best_podcasts(&json!({
39+
"genre_id": 23,
40+
})))
41+
.unwrap();
42+
assert!(response.is_object());
43+
assert!(response["total"].as_i64().unwrap() > 0);
44+
}
45+
46+
#[test]
47+
fn fetch_podcast_by_id() {
48+
let response = b!(client().fetch_podcast_by_id("dummy_id", &json!({}))).unwrap();
49+
assert!(response.is_object());
50+
assert!(response["episodes"].as_array().unwrap().len() > 0);
51+
}
52+
53+
#[test]
54+
fn batch_fetch_podcasts() {
55+
let response = b!(client().batch_fetch_podcasts(&json!({
56+
"ids": "996,777,888,1000"
57+
})))
58+
.unwrap();
59+
assert!(response.is_object());
60+
assert!(response["podcasts"].as_array().unwrap().len() > 0);
61+
}
62+
63+
#[test]
64+
fn fetch_episode_by_id() {
65+
let response = b!(client().fetch_episode_by_id("dummy_id", &json!({}))).unwrap();
66+
assert!(response.is_object());
67+
assert!(
68+
response["podcast"].as_object().unwrap()["rss"]
69+
.as_str()
70+
.unwrap()
71+
.len()
72+
> 0
73+
);
74+
}
75+
76+
#[test]
77+
fn batch_fetch_episodes() {
78+
let response = b!(client().batch_fetch_episodes(&json!({
79+
"ids": "996,777,888,1000"
80+
})))
81+
.unwrap();
82+
assert!(response.is_object());
83+
assert!(response["episodes"].as_array().unwrap().len() > 0);
84+
}
85+
}

0 commit comments

Comments
 (0)