use access_token_store::AccessTokenStore;
use anyhow::Context;
use log::debug;
use oauth::{AccessTokenRequest, AccessTokenResponse};
pub use requests::*;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::{convert::TryFrom, convert::TryInto, fmt::Debug, time::Duration};
use reqwest::{header, Url};
pub const API_ENDPOINT: &'static str = "https://www.scoop.it/api/1/";
pub const AUTHORIZATION_ENDPOINT: &'static str = "https://www.scoop.it/oauth/authorize";
pub const ACCESS_TOKEN_ENDPOINT: &'static str = "https://www.scoop.it/oauth2/token";
mod oauth;
pub mod requests;
pub mod types;
#[derive(Clone, Debug)]
pub struct ScoopitAPI {
endpoint: String,
authorization_endpoint: String,
access_token_endpoint: String,
}
impl Default for ScoopitAPI {
fn default() -> Self {
Self {
endpoint: API_ENDPOINT.to_string(),
authorization_endpoint: AUTHORIZATION_ENDPOINT.to_string(),
access_token_endpoint: ACCESS_TOKEN_ENDPOINT.to_string(),
}
}
}
impl ScoopitAPI {
pub fn custom(
endpoint: String,
authorization_endpoint: String,
access_token_endpoint: String,
) -> Self {
Self {
endpoint,
authorization_endpoint,
access_token_endpoint,
}
}
}
pub struct ScoopitAPIClient {
scoopit_api: ScoopitAPI,
client: reqwest::Client,
access_token: AccessTokenStore,
}
impl ScoopitAPIClient {
pub async fn authenticate_with_client_credentials(
scoopit_api: ScoopitAPI,
client_id: &str,
client_secret: &str,
) -> anyhow::Result<Self> {
if !scoopit_api.endpoint.ends_with("/") {
return Err(anyhow::anyhow!(
"Endpoint must ends with a trailing slash: {}",
scoopit_api.endpoint
));
}
let client = reqwest::ClientBuilder::new()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(60))
.default_headers({
let mut headers = header::HeaderMap::new();
headers.insert(
header::USER_AGENT,
header::HeaderValue::from_static("reqwest (scoopit-api-rs)"),
);
headers
})
.build()?;
let access_token = client
.post(Url::parse(&scoopit_api.access_token_endpoint)?)
.form(&AccessTokenRequest {
client_id: client_id,
client_secret: client_secret,
grant_type: "client_credentials",
refresh_token: None,
})
.send()
.await?
.error_for_status()?
.json::<AccessTokenResponse>()
.await?;
debug!("Creating client with access token: {:?}", access_token);
Ok(Self {
access_token: AccessTokenStore::new(
access_token.try_into()?,
scoopit_api.clone(),
client.clone(),
client_id.to_string(),
client_secret.to_string(),
),
scoopit_api,
client,
})
}
async fn do_get<T: DeserializeOwned>(&self, url: Url) -> anyhow::Result<T> {
Ok(self
.client
.get(url)
.header(
header::AUTHORIZATION,
format!("Bearer {}", self.access_token.get_access_token().await?),
)
.send()
.await?
.error_for_status()?
.json()
.await?)
}
pub async fn get<R>(&self, request: R) -> anyhow::Result<R::Output>
where
R: GetRequest + Debug,
{
let mut url = Url::parse(&self.scoopit_api.endpoint)?.join(R::endpoint())?;
url.set_query(Some(&serde_qs::to_string(&request)?));
let response: R::Response = self
.do_get(url)
.await
.with_context(|| format!("Cannot get from api, request: {:?}", request))?;
response.try_into()
}
}
pub struct AccessTokenRenew {
expires_at: u64,
refresh_token: String,
}
impl AccessTokenRenew {
pub fn new(expires_at: u64, refresh_token: String) -> Self {
Self {
expires_at,
refresh_token,
}
}
}
pub struct AccessToken {
access_token: String,
renew: Option<AccessTokenRenew>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Claims {
pub exp: Option<u64>,
}
impl AccessToken {
pub fn new(access_token: String) -> Self {
Self::with_renew(access_token, None)
}
pub fn with_renew(access_token: String, renew: Option<AccessTokenRenew>) -> Self {
Self {
access_token,
renew,
}
}
}
impl TryFrom<AccessTokenResponse> for AccessToken {
type Error = anyhow::Error;
fn try_from(r: AccessTokenResponse) -> Result<Self, Self::Error> {
let AccessTokenResponse {
access_token,
expires_in: _,
refresh_token,
} = r;
let decoded = jsonwebtoken::dangerous_insecure_decode::<Claims>(&access_token)?;
Ok(Self::with_renew(
access_token,
refresh_token
.map::<anyhow::Result<AccessTokenRenew>, _>(|refresh_token| {
Ok(AccessTokenRenew {
expires_at: decoded.claims.exp.ok_or(anyhow::anyhow!(
"Refresh token provided but access token does not expires!"
))?,
refresh_token,
})
})
.transpose()?,
))
}
}
mod access_token_store;
#[cfg(test)]
mod tests {
use crate::{
GetProfileRequest, GetTopicRequest, ScoopitAPIClient, SearchRequest, SearchRequestType,
};
async fn get_client() -> ScoopitAPIClient {
let _ = dotenv::dotenv();
let client_id = std::env::var("SCOOPIT_CLIENT_ID").unwrap();
let client_secret = std::env::var("SCOOPIT_CLIENT_SECRET").unwrap();
ScoopitAPIClient::authenticate_with_client_credentials(
Default::default(),
&client_id,
&client_secret,
)
.await
.unwrap()
}
#[tokio::test]
async fn get_profile() {
let user = get_client()
.await
.get(GetProfileRequest {
short_name: Some("pgassmann".to_string()),
..Default::default()
})
.await
.unwrap();
println!("{:#?}", user);
}
#[tokio::test]
async fn get_topic() {
let topic = get_client()
.await
.get(GetTopicRequest {
url_name: Some("sports-and-performance-psychology".to_string()),
..Default::default()
})
.await
.unwrap();
println!("{:#?}", topic);
}
#[tokio::test]
async fn search() {
let client = get_client().await;
println!(
"{:#?}",
client
.get(SearchRequest {
query: "test".to_string(),
search_type: SearchRequestType::Post,
count: Some(3),
..Default::default()
})
.await
.unwrap()
);
println!(
"{:#?}",
client
.get(SearchRequest {
query: "test".to_string(),
search_type: SearchRequestType::Topic,
count: Some(3),
..Default::default()
})
.await
.unwrap()
);
println!(
"{:#?}",
client
.get(SearchRequest {
query: "test".to_string(),
search_type: SearchRequestType::User,
count: Some(3),
..Default::default()
})
.await
.unwrap()
);
}
}