1use 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
16pub use reqwest;
18pub use url;
19
20mod access_token_store;
21mod oauth;
22pub mod requests;
23pub mod types;
24pub mod serde_qs;
27
28pub mod error;
29
30pub use access_token_store::AccessTokenStore;
31
32#[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
62pub struct ScoopitAPIClient {
67 scoopit_api: ScoopitAPI,
68 client: reqwest::Client,
69 access_token: AccessTokenStore,
70}
71
72impl ScoopitAPIClient {
73 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 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 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#[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#[derive(Debug)]
216pub struct AccessToken {
217 access_token: String,
218 renew: Option<AccessTokenRenew>,
219}
220
221#[derive(Serialize, Deserialize, Debug)]
223pub struct Claims {
224 pub exp: Option<u64>,
225}
226
227impl AccessToken {
228 pub fn new(access_token: String) -> Self {
232 Self::with_renew(access_token, None)
233 }
234
235 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 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 }