instapaper/
lib.rs

1//! Rust wrapper for the Instapaper public API.  The official API's documentation can be found
2//! [here](https://www.instapaper.com/api). Note that in order to receive a consumer key and secret
3//! to access the API you must fill out [this
4//! form](https://www.instapaper.com/main/request_oauth_consumer_token). See the `Client` struct for all methods made available.
5//!
6//! [Rustdocs](https://docs.rs/instapaper/)
7//!
8//! ## Installation
9//!
10//! Add `instapaper = "*"` to your `Cargo.toml`.
11//!
12//! ## Example
13//!
14//! ```
15//! extern crate dotenv;
16//!
17//! use dotenv::dotenv;
18//! use std::env;
19//!
20//! dotenv().ok();
21//!
22//! for (key, value) in env::vars() {
23//!   println!("{}: {}", key, value);
24//! }
25//!
26//! // Instapaper uses the archaic Oauth1 which requires the username and password in order to
27//! // receive an oauth token required for further operations.
28//! let client = instapaper::authenticate(
29//!     &env::var("INSTAPAPER_USERNAME").unwrap(),
30//!     &env::var("INSTAPAPER_PASSWORD").unwrap(),
31//!     &env::var("INSTAPAPER_CONSUMER_KEY").unwrap(),
32//!     &env::var("INSTAPAPER_CONSUMER_SECRET").unwrap(),
33//! ).expect("failed to authenticate");
34//!
35//!// Now the `oauth_key` and `oauth_secret` on `instapaper::Client` has been set to make it valid
36//!// for API actions
37//! client.add("https://sirupsen.com/read", "How I Read", "").unwrap();
38//! println!("{:?}", client.bookmarks().unwrap());
39//!
40//! println!("Client {{");
41//! println!("  consumer_key: {}", client.consumer_key);
42//! println!("  consumer_secret: {}", client.consumer_secret);
43//! println!("  oauth_key: {}", client.oauth_key.as_ref().unwrap());
44//! println!("  oauth_secret: {}", client.oauth_secret.as_ref().unwrap());
45//! println!("}}");
46//!
47//! // You can save the Oauth authentication details to e.g. an enviroment file or wherever you
48//! // store secrets and discard the username and password.
49//! let client2 = instapaper::Client {
50//!     consumer_key: env::var("INSTAPAPER_CONSUMER_KEY").unwrap().to_owned(),
51//!     consumer_secret: env::var("INSTAPAPER_CONSUMER_SECRET").unwrap().to_owned(),
52//!     oauth_key: client.oauth_key,
53//!     oauth_secret: client.oauth_secret,
54//! };
55//!
56//! println!("{:?}", client2.bookmarks().unwrap());
57//! ```
58//!
59extern crate serde;
60#[macro_use]
61extern crate serde_derive;
62extern crate serde_json;
63#[macro_use]
64extern crate failure;
65extern crate oauth1;
66extern crate reqwest;
67extern crate url;
68
69#[cfg(test)]
70extern crate mockito;
71
72use std::borrow::Cow;
73use std::collections::HashMap;
74use std::iter::FromIterator;
75
76use oauth1::Token;
77use url::Url;
78
79use failure::Error;
80
81#[cfg(not(test))]
82const URL: &str = "https://www.instapaper.com";
83#[cfg(test)]
84const URL: &str = mockito::SERVER_URL;
85
86pub type Result<T> = std::result::Result<T, Error>;
87
88/// The client instance to perform actions on. The `consumer_key` and `consumer_secret` are
89/// obtained through Instapaper's API documentation. The `oauth_key` and `oauth_secret` are
90/// obtained with the user's `username`, `password`, `consumer_key`, and `consumer_secret` by
91/// calling `authenticate()` on a Client.
92#[derive(Debug, Clone, Default)]
93pub struct Client {
94    pub consumer_key: String,
95    pub consumer_secret: String,
96    pub oauth_key: Option<String>,
97    pub oauth_secret: Option<String>,
98}
99
100/// Individual bookmarks, which is the API's lingo for a piece of media to be consumer later
101/// (video, article, etc.)
102#[derive(Deserialize, Debug, Clone, Default)]
103#[cfg_attr(test, derive(Serialize))]
104pub struct Bookmark {
105    pub title: String,
106    pub hash: String,
107    pub bookmark_id: i64,
108    pub progress_timestamp: f64,
109    pub description: String,
110    pub url: String,
111    pub time: f64,
112    pub starred: String,
113    #[serde(rename = "type")]
114    pub kind: String,
115    pub private_source: String,
116}
117
118/// Bare-bones information about the user.
119#[derive(Deserialize, Debug, Clone, Default)]
120#[cfg_attr(test, derive(Serialize))]
121pub struct User {
122    pub username: String,
123    pub user_id: i64,
124    #[serde(rename = "type")]
125    pub kind: String,
126    #[serde(rename = "subscription_is_active")]
127    pub subscription: String,
128}
129
130/// Individual article highlights.
131#[derive(Deserialize, Debug, Clone, Default)]
132#[cfg_attr(test, derive(Serialize))]
133pub struct Highlight {
134    pub highlight_id: i64,
135    pub bookmark_id: i64,
136    pub text: String,
137    pub note: Option<String>,
138    pub time: i64,
139    pub position: i64,
140    #[serde(rename = "type")]
141    pub kind: String,
142}
143
144/// API response from `bookmarks()` which contains highlights and bookmarks.
145#[derive(Deserialize, Debug, Clone, Default)]
146#[cfg_attr(test, derive(Serialize))]
147pub struct List {
148    pub bookmarks: Vec<Bookmark>,
149    pub user: User,
150    pub highlights: Vec<Highlight>,
151    #[serde(default)]
152    pub delete_ids: Vec<i64>,
153}
154
155/// Must be called to obtain the `oauth_key` and `oauth_secret`. Once you have them, you don't need
156/// to call this every time you want to access the API. You can store the resulting client's
157/// attributes somewhere and instantiate it yourself without this method. See the module-level
158/// documentation for a complete example.
159pub fn authenticate(username: &str, password: &str, consumer_key: &str, consumer_secret: &str) -> Result<Client> {
160    let mut params: HashMap<&str, Cow<str>> = HashMap::new();
161    params.insert("x_auth_username", Cow::Borrowed(username));
162    params.insert("x_auth_password", Cow::Borrowed(password));
163    params.insert("x_auth_mode", Cow::Borrowed("client_auth"));
164
165    let mut client = Client {
166        consumer_key: consumer_key.to_owned(),
167        consumer_secret: consumer_secret.to_owned(),
168        oauth_key: None,
169        oauth_secret: None,
170    };
171
172    let mut response = signed_request("oauth/access_token", params, &client)?;
173    let qline = response.text()?;
174
175    // TODO: This is such a roundabout way to properly parse the URI params, but I haven't found
176    // another API and this function doesn't take anything but a fully qualified path.
177    let qline = format!("https://junk.com/?{}", qline);
178    let url = Url::parse(&qline)?;
179    let query_params: HashMap<String, String> = HashMap::from_iter(url.query_pairs().into_owned());
180
181    let oauth_token = query_params.get("oauth_token");
182    let oauth_secret_token = query_params.get("oauth_token_secret");
183
184    if oauth_token.is_none() || oauth_secret_token.is_none() {
185        Err(format_err!("oauth_tokens not both in response: {}", qline))
186    } else {
187        client.oauth_key = Some(oauth_token.unwrap().to_owned());
188        client.oauth_secret = Some(oauth_secret_token.unwrap().to_owned());
189        Ok(client)
190    }
191}
192
193impl Client {
194    /// Verifies credentials, mostly used for testing.
195    pub fn verify(&self) -> Result<User> {
196        let params = HashMap::new();
197        let mut response = signed_request("account/verify_credentials", params, self)?;
198        let mut users: Vec<User> = response.json()?;
199        Ok(users.remove(0))
200    }
201
202    /// Move a `Bookmark` to the archive folder.
203    pub fn archive(&self, bookmark_id: i64) -> Result<Bookmark> {
204        let bookmark_id_string = bookmark_id.to_string();
205        let mut params: HashMap<&str, Cow<str>> = HashMap::new();
206        params.insert("bookmark_id", Cow::Borrowed(&bookmark_id_string));
207        let mut response = signed_request("bookmarks/archive", params, self)?;
208        let mut bookmarks: Vec<Bookmark> = response.json()?;
209        Ok(bookmarks.remove(0))
210    }
211
212    /// List all bookmarks and highlights in a folder. You'll need to obtain the folder id through either the API
213    /// or the URL on Instapaper. `unread` and `archive` work as strings.
214    pub fn bookmarks_in(&self, folder: &str) -> Result<List> {
215        let mut params: HashMap<&str, Cow<str>> = HashMap::new();
216        params.insert("limit", Cow::Borrowed("500"));
217        params.insert("folder_id", Cow::Borrowed(folder));
218        let mut response = signed_request("bookmarks/list", params, self)?;
219        response.json().map_err(|x| x.into())
220    }
221
222
223    /// List all bookmarks and highlights in the `unread` folder.
224    pub fn bookmarks(&self) -> Result<List> {
225        self.bookmarks_in("unread")
226    }
227
228    /// Add a bookmark. Pass a blank `title` and `description` if you want Instapaper's default.
229    pub fn add(&self, url: &str, title: &str, description: &str) -> Result<Bookmark> {
230        let mut params: HashMap<&str, Cow<str>> = HashMap::new();
231        params.insert("url", Cow::Borrowed(&url));
232        if !title.is_empty() {
233            params.insert("title", Cow::Borrowed(&title));
234        }
235        if !description.is_empty() {
236            params.insert("description", Cow::Borrowed(&description));
237        }
238
239        let mut response = signed_request("bookmarks/add", params, self)?;
240        let mut bookmarks: Vec<Bookmark> = response.json()?;
241        Ok(bookmarks.remove(0))
242    }
243}
244
245fn signed_request(
246    action: &str,
247    params: HashMap<&'static str, Cow<str>>,
248    client: &Client,
249) -> reqwest::Result<reqwest::Response> {
250    let http_client = reqwest::Client::new();
251    let url = format!("{}/api/1.1/{}", URL, action);
252    let empty = String::new();
253    let token = Token::new(
254        client.oauth_key.as_ref().unwrap_or(&empty),
255        client.oauth_secret.as_ref().unwrap_or(&empty),
256    );
257    let oauth: Option<&Token> = if client.oauth_key.as_ref().is_some() {
258        Some(&token)
259    } else {
260        None
261    };
262
263    let request = http_client
264        .post(&url)
265        .form(&params)
266        .header(
267            reqwest::header::AUTHORIZATION,
268            oauth1::authorize(
269                "POST",
270                &url,
271                &Token::new(
272                    &client.consumer_key,
273                    &client.consumer_secret,
274                ),
275                oauth,
276                Some(params),
277            ),
278        ).build()?;
279    http_client.execute(request)?.error_for_status()
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use mockito::mock;
286
287    fn client() -> Client {
288        Client {
289            consumer_key: String::new(),
290            consumer_secret: String::new(),
291            oauth_key: Some(String::new()),
292            oauth_secret: Some(String::new()),
293        }
294    }
295
296    #[test]
297    fn test_add_bookmark() {
298        let bookmark = vec![Bookmark {
299            title: "How I Read".to_string(),
300            ..Bookmark::default()
301        }];
302        let json = serde_json::to_string(&bookmark).unwrap();
303
304        let _m = mock("POST", "/api/1.1/bookmarks/add")
305            .with_status(201)
306            .with_header("content-type", "application/json")
307            .with_body(&json)
308            .create();
309
310        let result = client().add("https://sirupsen.com/read", "How I Read", "");
311        assert!(result.is_ok(), result.err().unwrap().to_string())
312    }
313
314    #[test]
315    fn test_add_bookmark_garbage_json() {
316        let _m = mock("POST", "/api/1.1/bookmarks/add")
317            .with_status(201)
318            .with_header("content-type", "application/json")
319            .with_body(r#"[garbageeee]"#)
320            .create();
321
322        let result = client().add("https://sirupsen.com/read", "How I Read", "");
323        assert!(result.is_err(), "Expected an error on garbage");
324        let err = result.err().unwrap();
325        assert_eq!("expected value at line 1 column 2", err.to_string());
326    }
327
328    #[test]
329    fn test_add_bookmark_error_code() {
330        let _m = mock("POST", "/api/1.1/bookmarks/add")
331            .with_status(500)
332            .with_header("content-type", "application/json")
333            .with_body(r#""#)
334            .create();
335
336        let result = client().add("https://sirupsen.com/read", "How I Read", "");
337        assert!(result.is_err(), "Expected an error on 500");
338    }
339
340    #[test]
341    fn test_authenticate() {
342        let _m = mock("POST", "/api/1.1/oauth/access_token")
343            .with_status(200)
344            .with_header("content-type", "application/text")
345            .with_body(r#"oauth_token=token&oauth_token_secret=secret"#)
346            .create();
347
348        let result = authenticate("username", "password", "key", "secret");
349        assert!(result.is_ok(), result.err().unwrap().to_string());
350        let client = result.unwrap();
351        assert_eq!("token", client.oauth_key.unwrap());
352        assert_eq!("secret", client.oauth_secret.unwrap());
353    }
354
355    #[test]
356    fn test_authenticate_reversed() {
357        let _m = mock("POST", "/api/1.1/oauth/access_token")
358            .with_status(200)
359            .with_header("content-type", "application/text")
360            .with_body(r#"oauth_token_secret=secret&oauth_token=token"#)
361            .create();
362
363        let result = authenticate("username", "password", "key", "secret");
364        assert!(result.is_ok(), result.err().unwrap().to_string());
365        let client = result.unwrap();
366        assert_eq!("token", client.oauth_key.unwrap());
367        assert_eq!("secret", client.oauth_secret.unwrap());
368    }
369
370    #[test]
371    fn test_authenticate_corrupted_qline() {
372        let _m = mock("POST", "/api/1.1/oauth/access_token")
373            .with_status(200)
374            .with_header("content-type", "application/text")
375            .with_body(r#"badqline"#)
376            .create();
377
378        let result = authenticate("username", "password", "key", "secret");
379        assert!(result.is_err(), "Expected an error");
380        let err = result.err().unwrap();
381        assert_eq!(
382            "oauth_tokens not both in response: https://junk.com/?badqline",
383            err.to_string()
384        )
385    }
386
387    #[test]
388    fn test_authenticate_qline_one_good_result() {
389        let _m = mock("POST", "/api/1.1/oauth/access_token")
390            .with_status(200)
391            .with_header("content-type", "application/text")
392            .with_body(r#"oauth_token=1&oauth_noep=walrus"#)
393            .create();
394
395        let result = authenticate("username", "password", "key", "secret");
396        assert!(result.is_err(), "Expected an error");
397        let err = result.err().unwrap();
398        assert_eq!(
399            "oauth_tokens not both in response: https://junk.com/?oauth_token=1&oauth_noep=walrus",
400            err.to_string()
401        )
402    }
403
404    #[test]
405    fn test_bookmarks() {
406        let list = List::default();
407        let json = serde_json::to_string(&list).unwrap();
408
409        let _m = mock("POST", "/api/1.1/bookmarks/list")
410            .with_status(201)
411            .with_header("content-type", "application/json")
412            .with_body(&json)
413            .create();
414
415        let result = client().bookmarks();
416        assert!(result.is_ok(), result.err().unwrap().to_string())
417    }
418
419    #[test]
420    fn test_bookmarks_error_status() {
421        let _m = mock("POST", "/api/1.1/bookmarks/list")
422            .with_status(500)
423            .with_header("content-type", "application/json")
424            .with_body("argh error!")
425            .create();
426
427        let result = client().bookmarks();
428        assert!(result.is_err(), "Expected an error on 500");
429    }
430
431    #[test]
432    fn test_verify() {
433        let user = vec![User::default()];
434        let json = serde_json::to_string(&user).unwrap();
435
436        let _m = mock("POST", "/api/1.1/account/verify_credentials")
437            .with_status(201)
438            .with_header("content-type", "application/json")
439            .with_body(&json)
440            .create();
441
442        let result = client().verify();
443        assert!(result.is_ok(), result.err().unwrap().to_string())
444    }
445
446    #[test]
447    fn test_verify_server_error() {
448        let _m = mock("POST", "/api/1.1/account/verify_credentials")
449            .with_status(500)
450            .with_header("content-type", "application/json")
451            .with_body("omgggg")
452            .create();
453
454        let result = client().verify();
455        assert!(result.is_err(), "Expected an error on 500");
456    }
457}