extern crate serde;
#[macro_use]
extern crate serde_derive;
extern crate serde_json;
#[macro_use]
extern crate failure;
extern crate oauth1;
extern crate reqwest;
extern crate url;
#[cfg(test)]
extern crate mockito;
use std::borrow::Cow;
use std::collections::HashMap;
use std::iter::FromIterator;
use oauth1::Token;
use url::Url;
use failure::Error;
#[cfg(not(test))]
const URL: &str = "https://www.instapaper.com";
#[cfg(test)]
const URL: &str = mockito::SERVER_URL;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone, Default)]
pub struct Client {
pub consumer_key: String,
pub consumer_secret: String,
pub oauth_key: Option<String>,
pub oauth_secret: Option<String>,
}
#[derive(Deserialize, Debug, Clone, Default)]
#[cfg_attr(test, derive(Serialize))]
pub struct Bookmark {
pub title: String,
pub hash: String,
pub bookmark_id: i64,
pub progress_timestamp: f64,
pub description: String,
pub url: String,
pub time: f64,
pub starred: String,
#[serde(rename = "type")]
pub kind: String,
pub private_source: String,
}
#[derive(Deserialize, Debug, Clone, Default)]
#[cfg_attr(test, derive(Serialize))]
pub struct User {
pub username: String,
pub user_id: i64,
#[serde(rename = "type")]
pub kind: String,
#[serde(rename = "subscription_is_active")]
pub subscription: String,
}
#[derive(Deserialize, Debug, Clone, Default)]
#[cfg_attr(test, derive(Serialize))]
pub struct Highlight {
pub highlight_id: i64,
pub bookmark_id: i64,
pub text: String,
pub note: Option<String>,
pub time: i64,
pub position: i64,
#[serde(rename = "type")]
pub kind: String,
}
#[derive(Deserialize, Debug, Clone, Default)]
#[cfg_attr(test, derive(Serialize))]
pub struct List {
pub bookmarks: Vec<Bookmark>,
pub user: User,
pub highlights: Vec<Highlight>,
#[serde(default)]
pub delete_ids: Vec<i64>,
}
pub fn authenticate(username: &str, password: &str, consumer_key: &str, consumer_secret: &str) -> Result<Client> {
let mut params: HashMap<&str, Cow<str>> = HashMap::new();
params.insert("x_auth_username", Cow::Borrowed(username));
params.insert("x_auth_password", Cow::Borrowed(password));
params.insert("x_auth_mode", Cow::Borrowed("client_auth"));
let mut client = Client {
consumer_key: consumer_key.to_owned(),
consumer_secret: consumer_secret.to_owned(),
oauth_key: None,
oauth_secret: None,
};
let mut response = signed_request("oauth/access_token", params, &client)?;
let qline = response.text()?;
let qline = format!("https://junk.com/?{}", qline);
let url = Url::parse(&qline)?;
let query_params: HashMap<String, String> = HashMap::from_iter(url.query_pairs().into_owned());
let oauth_token = query_params.get("oauth_token");
let oauth_secret_token = query_params.get("oauth_token_secret");
if oauth_token.is_none() || oauth_secret_token.is_none() {
Err(format_err!("oauth_tokens not both in response: {}", qline))
} else {
client.oauth_key = Some(oauth_token.unwrap().to_owned());
client.oauth_secret = Some(oauth_secret_token.unwrap().to_owned());
Ok(client)
}
}
impl Client {
pub fn verify(&self) -> Result<User> {
let params = HashMap::new();
let mut response = signed_request("account/verify_credentials", params, self)?;
let mut users: Vec<User> = response.json()?;
Ok(users.remove(0))
}
pub fn archive(&self, bookmark_id: i64) -> Result<Bookmark> {
let bookmark_id_string = bookmark_id.to_string();
let mut params: HashMap<&str, Cow<str>> = HashMap::new();
params.insert("bookmark_id", Cow::Borrowed(&bookmark_id_string));
let mut response = signed_request("bookmarks/archive", params, self)?;
let mut bookmarks: Vec<Bookmark> = response.json()?;
Ok(bookmarks.remove(0))
}
pub fn bookmarks_in(&self, folder: &str) -> Result<List> {
let mut params: HashMap<&str, Cow<str>> = HashMap::new();
params.insert("limit", Cow::Borrowed("500"));
params.insert("folder_id", Cow::Borrowed(folder));
let mut response = signed_request("bookmarks/list", params, self)?;
response.json().map_err(|x| x.into())
}
pub fn bookmarks(&self) -> Result<List> {
self.bookmarks_in("unread")
}
pub fn add(&self, url: &str, title: &str, description: &str) -> Result<Bookmark> {
let mut params: HashMap<&str, Cow<str>> = HashMap::new();
params.insert("url", Cow::Borrowed(&url));
if !title.is_empty() {
params.insert("title", Cow::Borrowed(&title));
}
if !description.is_empty() {
params.insert("description", Cow::Borrowed(&description));
}
let mut response = signed_request("bookmarks/add", params, self)?;
let mut bookmarks: Vec<Bookmark> = response.json()?;
Ok(bookmarks.remove(0))
}
}
fn signed_request(
action: &str,
params: HashMap<&'static str, Cow<str>>,
client: &Client,
) -> reqwest::Result<reqwest::Response> {
let http_client = reqwest::Client::new();
let url = format!("{}/api/1.1/{}", URL, action);
let empty = String::new();
let token = Token::new(
client.oauth_key.as_ref().unwrap_or(&empty),
client.oauth_secret.as_ref().unwrap_or(&empty),
);
let oauth: Option<&Token> = if client.oauth_key.as_ref().is_some() {
Some(&token)
} else {
None
};
let request = http_client
.post(&url)
.form(¶ms)
.header(
reqwest::header::AUTHORIZATION,
oauth1::authorize(
"POST",
&url,
&Token::new(
&client.consumer_key,
&client.consumer_secret,
),
oauth,
Some(params),
),
).build()?;
http_client.execute(request)?.error_for_status()
}
#[cfg(test)]
mod tests {
use super::*;
use mockito::mock;
fn client() -> Client {
Client {
consumer_key: String::new(),
consumer_secret: String::new(),
oauth_key: Some(String::new()),
oauth_secret: Some(String::new()),
}
}
#[test]
fn test_add_bookmark() {
let bookmark = vec![Bookmark {
title: "How I Read".to_string(),
..Bookmark::default()
}];
let json = serde_json::to_string(&bookmark).unwrap();
let _m = mock("POST", "/api/1.1/bookmarks/add")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(&json)
.create();
let result = client().add("https://sirupsen.com/read", "How I Read", "");
assert!(result.is_ok(), result.err().unwrap().to_string())
}
#[test]
fn test_add_bookmark_garbage_json() {
let _m = mock("POST", "/api/1.1/bookmarks/add")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(r#"[garbageeee]"#)
.create();
let result = client().add("https://sirupsen.com/read", "How I Read", "");
assert!(result.is_err(), "Expected an error on garbage");
let err = result.err().unwrap();
assert_eq!("expected value at line 1 column 2", err.to_string());
}
#[test]
fn test_add_bookmark_error_code() {
let _m = mock("POST", "/api/1.1/bookmarks/add")
.with_status(500)
.with_header("content-type", "application/json")
.with_body(r#""#)
.create();
let result = client().add("https://sirupsen.com/read", "How I Read", "");
assert!(result.is_err(), "Expected an error on 500");
}
#[test]
fn test_authenticate() {
let _m = mock("POST", "/api/1.1/oauth/access_token")
.with_status(200)
.with_header("content-type", "application/text")
.with_body(r#"oauth_token=token&oauth_token_secret=secret"#)
.create();
let result = authenticate("username", "password", "key", "secret");
assert!(result.is_ok(), result.err().unwrap().to_string());
let client = result.unwrap();
assert_eq!("token", client.oauth_key.unwrap());
assert_eq!("secret", client.oauth_secret.unwrap());
}
#[test]
fn test_authenticate_reversed() {
let _m = mock("POST", "/api/1.1/oauth/access_token")
.with_status(200)
.with_header("content-type", "application/text")
.with_body(r#"oauth_token_secret=secret&oauth_token=token"#)
.create();
let result = authenticate("username", "password", "key", "secret");
assert!(result.is_ok(), result.err().unwrap().to_string());
let client = result.unwrap();
assert_eq!("token", client.oauth_key.unwrap());
assert_eq!("secret", client.oauth_secret.unwrap());
}
#[test]
fn test_authenticate_corrupted_qline() {
let _m = mock("POST", "/api/1.1/oauth/access_token")
.with_status(200)
.with_header("content-type", "application/text")
.with_body(r#"badqline"#)
.create();
let result = authenticate("username", "password", "key", "secret");
assert!(result.is_err(), "Expected an error");
let err = result.err().unwrap();
assert_eq!(
"oauth_tokens not both in response: https://junk.com/?badqline",
err.to_string()
)
}
#[test]
fn test_authenticate_qline_one_good_result() {
let _m = mock("POST", "/api/1.1/oauth/access_token")
.with_status(200)
.with_header("content-type", "application/text")
.with_body(r#"oauth_token=1&oauth_noep=walrus"#)
.create();
let result = authenticate("username", "password", "key", "secret");
assert!(result.is_err(), "Expected an error");
let err = result.err().unwrap();
assert_eq!(
"oauth_tokens not both in response: https://junk.com/?oauth_token=1&oauth_noep=walrus",
err.to_string()
)
}
#[test]
fn test_bookmarks() {
let list = List::default();
let json = serde_json::to_string(&list).unwrap();
let _m = mock("POST", "/api/1.1/bookmarks/list")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(&json)
.create();
let result = client().bookmarks();
assert!(result.is_ok(), result.err().unwrap().to_string())
}
#[test]
fn test_bookmarks_error_status() {
let _m = mock("POST", "/api/1.1/bookmarks/list")
.with_status(500)
.with_header("content-type", "application/json")
.with_body("argh error!")
.create();
let result = client().bookmarks();
assert!(result.is_err(), "Expected an error on 500");
}
#[test]
fn test_verify() {
let user = vec![User::default()];
let json = serde_json::to_string(&user).unwrap();
let _m = mock("POST", "/api/1.1/account/verify_credentials")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(&json)
.create();
let result = client().verify();
assert!(result.is_ok(), result.err().unwrap().to_string())
}
#[test]
fn test_verify_server_error() {
let _m = mock("POST", "/api/1.1/account/verify_credentials")
.with_status(500)
.with_header("content-type", "application/json")
.with_body("omgggg")
.create();
let result = client().verify();
assert!(result.is_err(), "Expected an error on 500");
}
}