newsblur_api/
lib.rs

1pub mod error;
2#[cfg(test)]
3mod tests;
4
5pub use crate::error::{ApiError, ApiErrorKind};
6use failure::ResultExt;
7use reqwest::{multipart, Client, StatusCode};
8use serde_json::{self, Value};
9use url::Url;
10
11pub struct NewsBlurApi {
12    base_uri: Url,
13    username: String,
14    password: String,
15    cookie: Option<String>,
16}
17
18impl NewsBlurApi {
19    /// Create a new instance of the NewsBlurApi
20    pub fn new(url: &Url, username: &str, password: &str, cookie: Option<String>) -> Self {
21        NewsBlurApi {
22            base_uri: url.clone(),
23            username: username.to_string(),
24            password: password.to_string(),
25            cookie,
26        }
27    }
28
29    /// Login to NewsBlur. This must be called before other functions
30    ///
31    /// On success returns the cookie used for login
32    pub async fn login(&mut self, client: &Client) -> Result<String, ApiError> {
33        let form = multipart::Form::new()
34            .text("username", self.username.clone())
35            .text("password", self.password.clone());
36
37        let api_url: Url = self.base_uri.join("api/login").context(ApiErrorKind::Url)?;
38
39        let response = client
40            .post(api_url)
41            .header(reqwest::header::USER_AGENT, "curl/7.64.0")
42            .multipart(form)
43            .send()
44            .await
45            .context(ApiErrorKind::Http)?;
46
47        let status = response.status();
48        if status != StatusCode::OK {
49            return Err(ApiErrorKind::AccessDenied.into());
50        }
51
52        let cookie = response
53            .cookies()
54            .next()
55            .ok_or(ApiErrorKind::AccessDenied)?;
56        let cookie_string = format!("{}={}", cookie.name(), cookie.value());
57        self.cookie = Some(cookie_string.clone());
58
59        Ok(cookie_string)
60    }
61
62    /// Logout of NewsBlur
63    pub async fn logout(&self, client: &Client) -> Result<(), ApiError> {
64        let api_url: Url = self
65            .base_uri
66            .join("api/logout")
67            .context(ApiErrorKind::Url)?;
68
69        let response = client
70            .post(api_url)
71            .header(reqwest::header::USER_AGENT, "curl/7.64.0")
72            .header(reqwest::header::COOKIE, self.cookie.as_ref().unwrap())
73            .send()
74            .await
75            .context(ApiErrorKind::Http)?;
76
77        let status = response.status();
78        if status != StatusCode::OK {
79            return Err(ApiErrorKind::AccessDenied.into());
80        }
81
82        Ok(())
83    }
84
85    /// Sign up to NewsBlur
86    pub async fn signup(&self, _client: &Client) -> Result<(), ApiError> {
87        panic!("Unimplemented");
88    }
89
90    /// Retrieve information about a feed from its website or RSS address.
91    pub async fn search_feed(&self, client: &Client, address: &str) -> Result<(), ApiError> {
92        let form = multipart::Form::new().text("address", address.to_string());
93
94        let api_url: Url = self
95            .base_uri
96            .join("rss_feeds/search_feed")
97            .context(ApiErrorKind::Url)?;
98
99        let response = client
100            .get(api_url)
101            .header(reqwest::header::USER_AGENT, "curl/7.64.0")
102            .header(reqwest::header::COOKIE, self.cookie.as_ref().unwrap())
103            .multipart(form)
104            .send()
105            .await
106            .context(ApiErrorKind::Http)?;
107
108        let status = response.status();
109        if status != StatusCode::OK {
110            return Err(ApiErrorKind::AccessDenied.into());
111        }
112
113        Ok(())
114    }
115
116    /// Retrieve a list of feeds to which a user is actively subscribed.
117    pub async fn get_feeds(&self, client: &Client) -> Result<Value, ApiError> {
118        let form = multipart::Form::new().text("include_favicons", "false");
119
120        let api_url: Url = self
121            .base_uri
122            .join("reader/feeds")
123            .context(ApiErrorKind::Url)?;
124
125        let response = client
126            .get(api_url)
127            .header(reqwest::header::USER_AGENT, "curl/7.64.0")
128            .header(reqwest::header::COOKIE, self.cookie.as_ref().unwrap())
129            .multipart(form)
130            .send()
131            .await
132            .context(ApiErrorKind::Http)?;
133
134        let status = response.status();
135        if status != StatusCode::OK {
136            return Err(ApiErrorKind::AccessDenied.into());
137        }
138
139        let response_json = response.json().await.unwrap();
140
141        Ok(response_json)
142    }
143
144    /// Retrieve a list of favicons for a list of feeds. Used when combined
145    /// with /reader/feeds and include_favicons=false, so the feeds request
146    /// contains far less data. Useful for mobile devices, but requires a
147    /// second request.
148    pub async fn favicons(&self, client: &Client, feed_id: &str) -> Result<Value, ApiError> {
149        let form = multipart::Form::new().text("feed_ids", feed_id.to_string());
150
151        let api_url: Url = self
152            .base_uri
153            .join("reader/favicons")
154            .context(ApiErrorKind::Url)?;
155
156        let response = client
157            .get(api_url)
158            .header(reqwest::header::USER_AGENT, "curl/7.64.0")
159            .header(reqwest::header::COOKIE, self.cookie.as_ref().unwrap())
160            .multipart(form)
161            .send()
162            .await
163            .context(ApiErrorKind::Http)?;
164
165        let status = response.status();
166        if status != StatusCode::OK {
167            return Err(ApiErrorKind::AccessDenied.into());
168        }
169
170        let response_json = response.json().await.unwrap();
171
172        Ok(response_json)
173    }
174
175    /// Retrieve the original page from a single feed.
176    pub async fn get_original_page(&self, client: &Client, id: &str) -> Result<String, ApiError> {
177        let request = format!("reader/page/{}", id);
178
179        let api_url: Url = self.base_uri.join(&request).context(ApiErrorKind::Url)?;
180
181        let response = client
182            .get(api_url)
183            .header(reqwest::header::USER_AGENT, "curl/7.64.0")
184            .header(reqwest::header::COOKIE, self.cookie.as_ref().unwrap())
185            .send()
186            .await
187            .context(ApiErrorKind::Http)?;
188
189        let status = response.status();
190        if status != StatusCode::OK {
191            return Err(ApiErrorKind::AccessDenied.into());
192        }
193
194        let response_text = response.text().await.unwrap();
195
196        Ok(response_text)
197    }
198
199    /// Retrieve the original page from a single feed.
200    pub async fn get_original_text(&self, client: &Client, id: &str) -> Result<String, ApiError> {
201        let api_url: Url = self
202            .base_uri
203            .join("rss_feeds/original_text")
204            .context(ApiErrorKind::Url)?;
205
206        let query = vec![("story_hash", id.to_string())];
207
208        let response = client
209            .get(api_url)
210            .header(reqwest::header::USER_AGENT, "curl/7.64.0")
211            .header(reqwest::header::COOKIE, self.cookie.as_ref().unwrap())
212            .query(&query)
213            .send()
214            .await
215            .context(ApiErrorKind::Http)?;
216
217        let status = response.status();
218        if status != StatusCode::OK {
219            return Err(ApiErrorKind::AccessDenied.into());
220        }
221
222        let response_text = response.text().await.unwrap();
223
224        Ok(response_text)
225    }
226
227    /// Up-to-the-second unread counts for each active feed.
228    /// Poll for these counts no more than once a minute.
229    pub async fn refresh_feeds(&self, client: &Client) -> Result<Value, ApiError> {
230        let api_url: Url = self
231            .base_uri
232            .join("reader/refresh_feeds")
233            .context(ApiErrorKind::Url)?;
234
235        let response = client
236            .get(api_url)
237            .header(reqwest::header::USER_AGENT, "curl/7.64.0")
238            .header(reqwest::header::COOKIE, self.cookie.as_ref().unwrap())
239            .send()
240            .await
241            .context(ApiErrorKind::Http)?;
242
243        let status = response.status();
244        if status != StatusCode::OK {
245            return Err(ApiErrorKind::AccessDenied.into());
246        }
247
248        let response_json = response.json().await.unwrap();
249
250        Ok(response_json)
251    }
252
253    /// Feed of previously read stories.
254    pub async fn get_read_stories(&self, client: &Client, page: u32) -> Result<Value, ApiError> {
255        let mut query = Vec::new();
256        query.push(("page", format!("{}", page)));
257
258        let api_url: Url = self
259            .base_uri
260            .join("reader/read_stories")
261            .context(ApiErrorKind::Url)?;
262
263        let response = client
264            .get(api_url)
265            .header(reqwest::header::USER_AGENT, "curl/7.64.0")
266            .header(reqwest::header::COOKIE, self.cookie.as_ref().unwrap())
267            .query(&query)
268            .send()
269            .await
270            .context(ApiErrorKind::Http)?;
271
272        let status = response.status();
273        if status != StatusCode::OK {
274            return Err(ApiErrorKind::AccessDenied.into());
275        }
276
277        let response_json = response.json().await.unwrap();
278
279        Ok(response_json)
280    }
281
282    /// Retrieve stories from a single feed.
283    pub async fn get_stories(
284        &self,
285        client: &Client,
286        id: &str,
287        include_content: bool,
288        page: u32,
289    ) -> Result<Value, ApiError> {
290        let request = format!("reader/feed/{}", id);
291        let mut query = Vec::new();
292
293        if include_content {
294            query.push(("include_content", "true".to_string()));
295        } else {
296            query.push(("include_content", "false".to_string()));
297        }
298        query.push(("page", format!("{}", page)));
299
300        let api_url: Url = self.base_uri.join(&request).context(ApiErrorKind::Url)?;
301
302        let response = client
303            .get(api_url)
304            .header(reqwest::header::USER_AGENT, "curl/7.64.0")
305            .header(reqwest::header::COOKIE, self.cookie.as_ref().unwrap())
306            .query(&query)
307            .send()
308            .await
309            .context(ApiErrorKind::Http)?;
310
311        let status = response.status();
312        if status != StatusCode::OK {
313            return Err(ApiErrorKind::AccessDenied.into());
314        }
315
316        let response_json = response.json().await.unwrap();
317
318        Ok(response_json)
319    }
320
321    /// Mark stories as read using their unique story_hash.
322    pub async fn mark_stories_read(
323        &self,
324        client: &Client,
325        story_hash: &str,
326    ) -> Result<(), ApiError> {
327        let form = multipart::Form::new().text("story_hash", story_hash.to_string());
328
329        let api_url: Url = self
330            .base_uri
331            .join("reader/mark_story_hashes_as_read")
332            .context(ApiErrorKind::Url)?;
333
334        let response = client
335            .post(api_url)
336            .header(reqwest::header::USER_AGENT, "curl/7.64.0")
337            .header(reqwest::header::COOKIE, self.cookie.as_ref().unwrap())
338            .multipart(form)
339            .send()
340            .await
341            .context(ApiErrorKind::Http)?;
342
343        let status = response.status();
344        if status != StatusCode::OK {
345            return Err(ApiErrorKind::AccessDenied.into());
346        }
347
348        Ok(())
349    }
350
351    /// Mark a single story as unread using its unique story_hash.
352    pub async fn mark_story_unread(
353        &self,
354        client: &Client,
355        story_hash: &str,
356    ) -> Result<(), ApiError> {
357        let form = multipart::Form::new().text("story_hash", story_hash.to_string());
358
359        let api_url: Url = self
360            .base_uri
361            .join("reader/mark_story_hash_as_unread")
362            .context(ApiErrorKind::Url)?;
363
364        let response = client
365            .post(api_url)
366            .header(reqwest::header::USER_AGENT, "curl/7.64.0")
367            .header(reqwest::header::COOKIE, self.cookie.as_ref().unwrap())
368            .multipart(form)
369            .send()
370            .await
371            .context(ApiErrorKind::Http)?;
372
373        let status = response.status();
374        if status != StatusCode::OK {
375            return Err(ApiErrorKind::AccessDenied.into());
376        }
377
378        Ok(())
379    }
380
381    /// Mark a story as starred (saved).
382    pub async fn mark_story_hash_as_starred(
383        &self,
384        client: &Client,
385        story_hash: &str,
386    ) -> Result<(), ApiError> {
387        let form = multipart::Form::new().text("story_hash", story_hash.to_string());
388
389        let api_url: Url = self
390            .base_uri
391            .join("reader/mark_story_hash_as_starred")
392            .context(ApiErrorKind::Url)?;
393
394        let response = client
395            .post(api_url)
396            .header(reqwest::header::USER_AGENT, "curl/7.64.0")
397            .header(reqwest::header::COOKIE, self.cookie.as_ref().unwrap())
398            .multipart(form)
399            .send()
400            .await
401            .context(ApiErrorKind::Http)?;
402
403        let status = response.status();
404        if status != StatusCode::OK {
405            return Err(ApiErrorKind::AccessDenied.into());
406        }
407
408        Ok(())
409    }
410
411    /// Mark a story as unstarred (unsaved).
412    pub async fn mark_story_hash_as_unstarred(
413        &self,
414        client: &Client,
415        story_hash: &str,
416    ) -> Result<(), ApiError> {
417        let form = multipart::Form::new().text("story_hash", story_hash.to_string());
418
419        let api_url: Url = self
420            .base_uri
421            .join("reader/mark_story_hash_as_unstarred")
422            .context(ApiErrorKind::Url)?;
423
424        let response = client
425            .post(api_url)
426            .header(reqwest::header::USER_AGENT, "curl/7.64.0")
427            .header(reqwest::header::COOKIE, self.cookie.as_ref().unwrap())
428            .multipart(form)
429            .send()
430            .await
431            .context(ApiErrorKind::Http)?;
432
433        let status = response.status();
434        if status != StatusCode::OK {
435            return Err(ApiErrorKind::AccessDenied.into());
436        }
437
438        Ok(())
439    }
440
441    /// The story_hashes of all unread stories.
442    /// Useful for offline access of stories and quick unread syncing.
443    /// Use include_timestamps to fetch stories in date order.
444    pub async fn get_unread_story_hashes(&self, client: &Client) -> Result<Value, ApiError> {
445        let api_url: Url = self
446            .base_uri
447            .join("reader/unread_story_hashes")
448            .context(ApiErrorKind::Url)?;
449
450        let response = client
451            .get(api_url)
452            .header(reqwest::header::USER_AGENT, "curl/7.64.0")
453            .header(reqwest::header::COOKIE, self.cookie.as_ref().unwrap())
454            .send()
455            .await
456            .context(ApiErrorKind::Http)?;
457
458        let status = response.status();
459        if status != StatusCode::OK {
460            return Err(ApiErrorKind::AccessDenied.into());
461        }
462
463        let response_json = response.json().await.unwrap();
464
465        Ok(response_json)
466    }
467
468    pub async fn get_stared_story_hashes(&self, client: &Client) -> Result<Value, ApiError> {
469        let api_url: Url = self
470            .base_uri
471            .join("reader/starred_story_hashes")
472            .context(ApiErrorKind::Url)?;
473
474        let response = client
475            .get(api_url)
476            .header(reqwest::header::USER_AGENT, "curl/7.64.0")
477            .header(reqwest::header::COOKIE, self.cookie.as_ref().unwrap())
478            .send()
479            .await
480            .context(ApiErrorKind::Http)?;
481
482        let status = response.status();
483        if status != StatusCode::OK {
484            return Err(ApiErrorKind::AccessDenied.into());
485        }
486
487        let response_json = response.json().await.unwrap();
488
489        Ok(response_json)
490    }
491
492    /// Retrieve up to 100 stories when specifying by story_hash.
493    pub async fn get_river_stories(
494        &self,
495        client: &Client,
496        hashes: &[&str],
497    ) -> Result<Value, ApiError> {
498        let api_url: Url = self
499            .base_uri
500            .join("reader/river_stories")
501            .context(ApiErrorKind::Url)?;
502        let mut query = Vec::new();
503
504        for hash in hashes {
505            query.push(("h", hash));
506        }
507
508        let response = client
509            .get(api_url)
510            .header(reqwest::header::USER_AGENT, "curl/7.64.0")
511            .header(reqwest::header::COOKIE, self.cookie.as_ref().unwrap())
512            .query(&query)
513            .send()
514            .await
515            .context(ApiErrorKind::Http)?;
516
517        let status = response.status();
518        if status != StatusCode::OK {
519            return Err(ApiErrorKind::AccessDenied.into());
520        }
521
522        let response_json = response.json().await.unwrap();
523
524        Ok(response_json)
525    }
526
527    pub async fn mark_feed_read(&self, client: &Client, feed_id: &str) -> Result<(), ApiError> {
528        let form = multipart::Form::new().text("feed_id", feed_id.to_string());
529
530        let api_url: Url = self
531            .base_uri
532            .join("reader/mark_feed_as_read")
533            .context(ApiErrorKind::Url)?;
534
535        let response = client
536            .post(api_url)
537            .header(reqwest::header::USER_AGENT, "curl/7.64.0")
538            .header(reqwest::header::COOKIE, self.cookie.as_ref().unwrap())
539            .multipart(form)
540            .send()
541            .await
542            .context(ApiErrorKind::Http)?;
543
544        let status = response.status();
545        if status != StatusCode::OK {
546            return Err(ApiErrorKind::AccessDenied.into());
547        }
548
549        Ok(())
550    }
551
552    pub async fn mark_all_read(&self, client: &Client) -> Result<(), ApiError> {
553        let api_url: Url = self
554            .base_uri
555            .join("reader/mark_all_as_read")
556            .context(ApiErrorKind::Url)?;
557
558        let response = client
559            .post(api_url)
560            .header(reqwest::header::USER_AGENT, "curl/7.64.0")
561            .header(reqwest::header::COOKIE, self.cookie.as_ref().unwrap())
562            .send()
563            .await
564            .context(ApiErrorKind::Http)?;
565
566        let status = response.status();
567        if status != StatusCode::OK {
568            return Err(ApiErrorKind::AccessDenied.into());
569        }
570
571        Ok(())
572    }
573}