Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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"
```
9 changes: 6 additions & 3 deletions src/api.rs
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
124 changes: 92 additions & 32 deletions src/client.rs
Original file line number Diff line number Diff line change
@@ -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<String>) -> 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 {
Expand All @@ -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<Value> {
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<Value> {
self.get("typeahead", parameters).await
}

pub async fn episode_by_id(&self, id: &str, parameters: &Value) -> Result<Value> {
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<Value> {
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<Value> {
self.get(&format!("podcasts/{}", id), parameters).await
}

pub async fn episodes(&self, ids: &[&str], parameters: &Value) -> Result<Value> {
self.post("episodes", &parameters.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<Value> {
self.post("podcasts", parameters).await
}

pub async fn genres(&self, parameters: &Value) -> Result<Value> {
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<Value> {
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<Value> {
self.post("episodes", parameters).await
}

async fn get(&self, endpoint: &str, parameters: &Value) -> Result<Value> {
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<Value> {
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(&parameters)?) // 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<Value> {
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::<Vec<String>>()
.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"
);
}
}
3 changes: 3 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -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),
}

Expand Down
38 changes: 38 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -6,4 +43,5 @@ use api::Api;

pub use client::Client;
pub use error::Error;
/// Result for API calls from [`Client`]
pub type Result<T> = std::result::Result<T, error::Error>;
85 changes: 85 additions & 0 deletions tests/client_tests.rs
Original file line number Diff line number Diff line change
@@ -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() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe add a test_* prefix to each unit test name? Making this test_search()
not sure if it's conventional to have both unit test & the tested function have the same name.

Copy link
Collaborator Author

@cameronfyfe cameronfyfe May 16, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't say there's widespread consensus on this but this way of doing it isn't uncommon since it's already in a test file and you don't need to worry about name/symbol collision. This is how I normally do it but it's largely personal opinion so I'll leave it up to you if you want these as X or test_X. Happy to change them if you want

let response = b!(client().search(&json!({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's test 4 things for each client function:

1, see if the endpoint url is correct (sometimes we copy & paste to make a function hit the wrong url)

2, see if the http method is correct. For this function, it should be GET; for some other functions, it should be POST or DELETE

3, see if parameters are passed okay. For GET functions, check if query strings contain the expected parameters.

4, see if the response json is good, which you already did.

Example: https://github.com/ListenNotes/podcast-api-python/blob/main/tests/test_client.py#L16

Copy link
Collaborator Author

@cameronfyfe cameronfyfe May 16, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this would be a bit awkward to add to the existing tests as I don't like any of the solutions for making that information user facing but I could test those items separately with a module mocking your API expecting those properties from the client.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's make a new Response struct similar to the one in our swift sdk:
https://github.com/ListenNotes/podcast-api-swift/blob/main/Sources/PodcastAPI/ApiResponse.swift#L5

We need users to be able to access stats returned from response headers and maybe the raw request object.

"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);
}
}