scoopit_api/
lib.rs

1//! # Rust client for www.scoop.it REST API
2//!
3//! The client uses `reqwest` with `rustls` to perform HTTP requests to www.scoop.it API.
4use anyhow::Context;
5use jsonwebtokens::raw::TokenSlices;
6use log::debug;
7use oauth::AccessTokenResponse;
8pub use requests::*;
9use reqwest::header::CONTENT_TYPE;
10use serde::de::DeserializeOwned;
11use serde::{Deserialize, Serialize};
12use std::{convert::TryFrom, convert::TryInto, fmt::Debug, time::Duration};
13
14use reqwest::{header, RequestBuilder, Url};
15
16// reexport crates
17pub use reqwest;
18pub use url;
19
20mod access_token_store;
21mod oauth;
22pub mod requests;
23pub mod types;
24// Note we are using a very hacked slimmed&vendored version of serde_qs to allow serializing Vec in form of
25// vec=foo&vec=bar&vec=baz instead of regular serde_qs vec[1]=foo&vec[2]=bar&vec[3]=baz
26pub mod serde_qs;
27
28pub mod error;
29
30pub use access_token_store::AccessTokenStore;
31
32/// Scoop.it API endpoints.
33///
34/// Use the `default()` method to get the default endpoints.
35#[derive(Clone, Debug)]
36pub struct ScoopitAPI {
37    endpoint: Url,
38    authorization_endpoint: Url,
39    access_token_endpoint: Url,
40}
41
42impl Default for ScoopitAPI {
43    fn default() -> Self {
44        Self::custom(Url::parse("https://www.scoop.it").unwrap()).unwrap()
45    }
46}
47
48impl ScoopitAPI {
49    pub fn custom(base_url: Url) -> anyhow::Result<Self> {
50        Ok(Self {
51            endpoint: base_url.join("/api/1/")?,
52            authorization_endpoint: base_url.join("/oauth/authorize")?,
53            access_token_endpoint: base_url.join("/oauth2/token")?,
54        })
55    }
56
57    pub fn with_endpoint(self, endpoint: Url) -> Self {
58        Self { endpoint, ..self }
59    }
60}
61
62/// The client for the scoop.it API.
63///
64/// All requests done by the client are authenticated using an access token. The token
65/// is automatically renewed be needed.
66pub struct ScoopitAPIClient {
67    scoopit_api: ScoopitAPI,
68    client: reqwest::Client,
69    access_token: AccessTokenStore,
70}
71
72impl ScoopitAPIClient {
73    /// Create a scoopit api client authenticated using client credentials authentication.
74    ///
75    /// Access token is automatically requested from scoop.it upon the creation of the client
76    /// using the `client_credelentials` grant type. If it fails, an error is returned.
77    pub async fn authenticate_with_client_credentials(
78        scoopit_api: ScoopitAPI,
79        client_id: &str,
80        client_secret: &str,
81    ) -> anyhow::Result<Self> {
82        let client = ScoopitAPIClient::create_client()?;
83
84        let access_token = access_token_store::authenticate_with_client_credentials(
85            &client,
86            &scoopit_api,
87            client_id,
88            client_secret,
89        )
90        .await?;
91
92        debug!("Creating client with access token: {:?}", access_token);
93
94        Ok(Self {
95            access_token: AccessTokenStore::new(
96                access_token,
97                scoopit_api.clone(),
98                client.clone(),
99                client_id.to_string(),
100                client_secret.to_string(),
101            ),
102            scoopit_api,
103            client,
104        })
105    }
106
107    pub fn new(
108        scoopit_api: ScoopitAPI,
109        access_token_store: AccessTokenStore,
110    ) -> anyhow::Result<Self> {
111        Ok(Self {
112            access_token: access_token_store,
113            client: ScoopitAPIClient::create_client()?,
114            scoopit_api,
115        })
116    }
117
118    fn create_client() -> anyhow::Result<reqwest::Client> {
119        Ok(reqwest::ClientBuilder::new()
120            .connect_timeout(Duration::from_secs(5))
121            .timeout(Duration::from_secs(60))
122            .default_headers({
123                let mut headers = header::HeaderMap::new();
124                headers.insert(
125                    header::USER_AGENT,
126                    header::HeaderValue::from_static("reqwest (scoopit-api-rs)"),
127                );
128                headers
129            })
130            .build()?)
131    }
132
133    async fn do_request<T: DeserializeOwned>(
134        &self,
135        request: RequestBuilder,
136    ) -> Result<T, error::Error> {
137        let json = request
138            .header(
139                header::AUTHORIZATION,
140                format!("Bearer {}", self.access_token.get_access_token().await?),
141            )
142            .send()
143            .await?
144            .error_for_status()?
145            .text()
146            .await?;
147        debug!("Received response {json}");
148        Ok(serde_json::from_str::<T>(&json)?)
149    }
150
151    /// Perform a `GET` request to scoop.it API.
152    ///
153    /// The request must immplements the `GetRequest` trait which specifies
154    /// serialization format of the response and conversion method to the actual
155    /// output type.
156    pub async fn get<R>(&self, request: R) -> Result<R::Output, error::Error>
157    where
158        R: GetRequest + Debug,
159    {
160        let mut url = self
161            .scoopit_api
162            .endpoint
163            .join(request.endpoint().as_ref())
164            .context("Cannot build the url")?;
165        url.set_query(Some(
166            &serde_qs::to_string(&request).context("Cannot build the url")?,
167        ));
168        let response: R::Response = self.do_request(self.client.get(url)).await?;
169
170        response.try_into().map_err(error::Error::from)
171    }
172
173    /// Perform a request with a triggers an update (or an action) to scoop.it API.
174    ///
175    /// The request must implements the `UpdateRequest` trait.
176    pub async fn update<R>(&self, request: R) -> Result<R::Output, error::Error>
177    where
178        R: UpdateRequest + Debug,
179    {
180        let url = self
181            .scoopit_api
182            .endpoint
183            .join(request.endpoint().as_ref())
184            .context("Cannot build the url")?;
185
186        let response: R::Response = self
187            .do_request(
188                self.client
189                    .request(request.method(), url)
190                    .header(CONTENT_TYPE, R::content_type())
191                    .body(request.body()?),
192            )
193            .await?;
194
195        response.try_into().map_err(error::Error::from)
196    }
197}
198
199/// Renewal data of an access token
200#[derive(Debug)]
201pub struct AccessTokenRenew {
202    expires_at: u64,
203    refresh_token: String,
204}
205impl AccessTokenRenew {
206    pub fn new(expires_at: u64, refresh_token: String) -> Self {
207        Self {
208            expires_at,
209            refresh_token,
210        }
211    }
212}
213
214/// An access token
215#[derive(Debug)]
216pub struct AccessToken {
217    access_token: String,
218    renew: Option<AccessTokenRenew>,
219}
220
221// we are only interested by the expiration
222#[derive(Serialize, Deserialize, Debug)]
223pub struct Claims {
224    pub exp: Option<u64>,
225}
226
227impl AccessToken {
228    /// Creates a never expiring access token.
229    ///
230    /// This token will never be renewed. If it comes to expire, all requests using it will fail.
231    pub fn new(access_token: String) -> Self {
232        Self::with_renew(access_token, None)
233    }
234
235    /// Creates an access token.
236    ///
237    /// If `renew` is provided the access will automatically renewed if needed.
238    pub fn with_renew(access_token: String, renew: Option<AccessTokenRenew>) -> Self {
239        Self {
240            access_token,
241            renew,
242        }
243    }
244}
245
246impl TryFrom<AccessTokenResponse> for AccessToken {
247    type Error = anyhow::Error;
248
249    fn try_from(r: AccessTokenResponse) -> Result<Self, Self::Error> {
250        let AccessTokenResponse {
251            access_token,
252            expires_in: _,
253            refresh_token,
254        } = r;
255        let exp = {
256            let TokenSlices { claims, .. } = jsonwebtokens::raw::split_token(&access_token)?;
257            let json_claims = jsonwebtokens::raw::decode_json_token_slice(claims)?;
258            serde_json::from_value::<Claims>(json_claims)?.exp
259        };
260
261        Ok(Self::with_renew(
262            access_token,
263            refresh_token
264                .map::<anyhow::Result<AccessTokenRenew>, _>(|refresh_token| {
265                    Ok(AccessTokenRenew {
266                        expires_at: exp.ok_or(anyhow::anyhow!(
267                            "Refresh token provided but access token does not expires!"
268                        ))?,
269                        refresh_token,
270                    })
271                })
272                .transpose()?,
273        ))
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use crate::{
280        GetProfileRequest, GetTopicOrder, GetTopicRequest, ScoopitAPIClient, SearchRequest,
281        SearchRequestType, TestRequest,
282    };
283
284    use std::sync::Once;
285
286    static INIT: Once = Once::new();
287
288    /// Setup function that is only run once, even if called multiple times.
289    fn setup_logger() {
290        INIT.call_once(|| {
291            env_logger::init();
292        });
293    }
294
295    async fn get_client() -> ScoopitAPIClient {
296        let _ = dotenv::dotenv();
297        setup_logger();
298        let client_id = std::env::var("SCOOPIT_CLIENT_ID").unwrap();
299        let client_secret = std::env::var("SCOOPIT_CLIENT_SECRET").unwrap();
300        ScoopitAPIClient::authenticate_with_client_credentials(
301            Default::default(),
302            &client_id,
303            &client_secret,
304        )
305        .await
306        .unwrap()
307    }
308
309    #[tokio::test]
310    async fn get_profile() {
311        let client = get_client().await;
312        let user = client
313            .get(GetProfileRequest {
314                short_name: Some("pgassmann".to_string()),
315                ..Default::default()
316            })
317            .await;
318
319        println!("{:#?}", user.unwrap());
320
321        assert!(client
322            .get(GetProfileRequest {
323                short_name: Some("pgassmann-a-profile-that-should-not-exists".to_string()),
324                ..Default::default()
325            })
326            .await
327            .unwrap_err()
328            .is_not_found());
329    }
330
331    #[tokio::test]
332    async fn get_topic() {
333        let topic = get_client()
334            .await
335            .get(GetTopicRequest {
336                url_name: Some("sports-and-performance-psychology".to_string()),
337                ..Default::default()
338            })
339            .await
340            .unwrap();
341        println!("{:#?}", topic);
342
343        let topic = get_client()
344            .await
345            .get(GetTopicRequest {
346                url_name: Some("sports-and-performance-psychology".to_string()),
347                order: Some(GetTopicOrder::User),
348                ..Default::default()
349            })
350            .await
351            .unwrap();
352        println!("{:#?}", topic);
353    }
354
355    #[tokio::test]
356    async fn get_topic_with_tags() {
357        let client = get_client().await;
358
359        let topic = client
360            .get(GetTopicRequest {
361                url_name: Some("best-of-photojournalism".to_string()),
362                order: Some(GetTopicOrder::Tag),
363                tag: Some(vec!["afghanistan".to_string()]),
364                ..Default::default()
365            })
366            .await
367            .unwrap();
368        println!("{:#?}", topic);
369
370        assert!(client
371            .get(GetTopicRequest {
372                url_name: Some("best-of-photojournalism-that-must-not-exists-yolo".to_string()),
373                ..Default::default()
374            })
375            .await
376            .unwrap_err()
377            .is_not_found());
378    }
379
380    #[tokio::test]
381    async fn get_test() {
382        let response = get_client()
383            .await
384            .get(TestRequest::default())
385            .await
386            .unwrap();
387        println!("{:#?}", response);
388    }
389
390    #[tokio::test]
391    async fn search() {
392        let client = get_client().await;
393        println!(
394            "{:#?}",
395            client
396                .get(SearchRequest {
397                    query: "test".to_string(),
398                    search_type: SearchRequestType::Post,
399                    count: Some(3),
400                    ..Default::default()
401                })
402                .await
403                .unwrap()
404        );
405        println!(
406            "{:#?}",
407            client
408                .get(SearchRequest {
409                    query: "test".to_string(),
410                    search_type: SearchRequestType::Topic,
411                    count: Some(3),
412                    ..Default::default()
413                })
414                .await
415                .unwrap()
416        );
417        println!(
418            "{:#?}",
419            client
420                .get(SearchRequest {
421                    query: "test".to_string(),
422                    search_type: SearchRequestType::User,
423                    count: Some(3),
424                    ..Default::default()
425                })
426                .await
427                .unwrap()
428        );
429    }
430    /*
431    #[tokio::test]
432    async fn login() {
433        let client = get_client().await;
434
435        let result = client
436            .post(LoginRequest {
437                email: std::env::var("SCOOPIT_TEST_EMAIL").unwrap(),
438                password: std::env::var("SCOOPIT_TEST_PWD").unwrap(),
439            })
440            .await
441            .unwrap();
442
443        println!("{:#?}", result)
444    }
445    */
446}