mod error;
pub mod handlers;
pub mod media;
pub mod models;
pub mod npf;
pub mod oauth;
mod response;
pub use error::{CrabError, CrabResult};
pub use handlers::{Blogs, Communities, Tagged, Users};
pub use models::{Blog, BlogIdentifier, Page, User};
pub use response::{ApiResponse, EmptyResponse, Meta};
use base64::Engine;
use hmac::{Hmac, Mac};
use rand::Rng;
use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
use serde::{Deserialize, Deserializer};
use sha1::Sha1;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::handlers::blog::{AvatarResponse, AvatarResponseUrl};
pub const BASE_API_URL: &str = "https://api.tumblr.com/v2";
pub const OAUTH_AUTHORIZE_URL: &str = "https://www.tumblr.com/oauth2/authorize";
pub const OAUTH_TOKEN_URL: &str = "https://api.tumblr.com/v2/oauth2/token";
const DEFAULT_USER_AGENT: &str = concat!(
"crabrave/",
env!("CARGO_PKG_VERSION"),
" (Rust HTTP Client for Tumblr)"
);
#[derive(Clone)]
enum Auth {
OAuth2 {
#[allow(dead_code)] consumer_key: String,
#[allow(dead_code)] consumer_secret: String,
access_token: String,
},
OAuth1 {
consumer_key: String,
consumer_secret: String,
access_token: String,
access_token_secret: String,
},
ApiKey { consumer_key: String },
}
#[derive(Clone)]
pub struct Crabrave {
client: reqwest::Client,
base_url: String,
auth: Arc<Auth>,
}
impl Crabrave {
pub fn builder() -> CrabraveBuilder {
CrabraveBuilder::new()
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn client(&self) -> &reqwest::Client {
&self.client
}
pub fn blogs(&self, identifier: impl Into<BlogIdentifier>) -> Blogs {
Blogs::new(self.clone(), identifier.into())
}
pub fn users(&self) -> Users {
Users::new(self.clone())
}
pub fn tagged(&self, tag: impl Into<String>) -> handlers::Tagged {
handlers::Tagged::new(self.clone(), tag.into())
}
pub fn communities(&self, handle: impl Into<String>) -> Communities {
Communities::new(self.clone(), handle.into())
}
pub(crate) fn url(&self, path: &str) -> String {
let path = path.trim_start_matches('/');
format!("{}/{}", self.base_url, path)
}
fn generate_oauth1_signature(
&self,
method: &str,
url: &str,
consumer_key: &str,
consumer_secret: &str,
access_token: &str,
access_token_secret: &str,
) -> String {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_else(|_| "0".to_string());
let nonce: String = rand::rng()
.sample_iter(&rand::distr::Alphanumeric)
.take(32)
.map(char::from)
.collect();
let mut params: BTreeMap<String, String> = BTreeMap::new();
params.insert("oauth_consumer_key".to_string(), consumer_key.to_string());
params.insert("oauth_nonce".to_string(), nonce.clone());
params.insert(
"oauth_signature_method".to_string(),
"HMAC-SHA1".to_string(),
);
params.insert("oauth_timestamp".to_string(), timestamp.clone());
params.insert("oauth_token".to_string(), access_token.to_string());
params.insert("oauth_version".to_string(), "1.0".to_string());
if let Ok(parsed_url) = url::Url::parse(url) {
for (key, value) in parsed_url.query_pairs() {
params.insert(key.to_string(), value.to_string());
}
}
let param_string: String = params
.iter()
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
let base_url = url.split('?').next().unwrap_or(url);
let signature_base = format!(
"{}&{}&{}",
urlencoding::encode(method),
urlencoding::encode(base_url),
urlencoding::encode(¶m_string)
);
let signing_key = format!(
"{}&{}",
urlencoding::encode(consumer_secret),
urlencoding::encode(access_token_secret)
);
type HmacSha1 = Hmac<Sha1>;
#[allow(clippy::expect_used)]
let mut mac = HmacSha1::new_from_slice(signing_key.as_bytes())
.expect("HMAC-SHA1 accepts keys of any size");
mac.update(signature_base.as_bytes());
let result = mac.finalize();
let signature = base64::engine::general_purpose::STANDARD.encode(result.into_bytes());
format!(
r#"OAuth oauth_consumer_key="{}", oauth_nonce="{}", oauth_signature="{}", oauth_signature_method="HMAC-SHA1", oauth_timestamp="{}", oauth_token="{}", oauth_version="1.0""#,
urlencoding::encode(consumer_key),
urlencoding::encode(&nonce),
urlencoding::encode(&signature),
timestamp,
urlencoding::encode(access_token)
)
}
fn apply_auth(
&self,
mut request: reqwest::RequestBuilder,
method: &str,
url: &str,
) -> reqwest::RequestBuilder {
match self.auth.as_ref() {
Auth::OAuth2 { access_token, .. } => {
request = request.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", access_token),
);
}
Auth::OAuth1 {
consumer_key,
consumer_secret,
access_token,
access_token_secret,
} => {
let auth_header = self.generate_oauth1_signature(
method,
url,
consumer_key,
consumer_secret,
access_token,
access_token_secret,
);
request = request.header(reqwest::header::AUTHORIZATION, auth_header);
}
Auth::ApiKey { consumer_key } => {
request = request.query(&[("api_key", consumer_key)]);
}
}
request
}
fn check_rate_limit(response: &reqwest::Response) -> CrabResult<()> {
if response.status().as_u16() == 429 {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok());
return Err(CrabError::RateLimit { retry_after });
}
Ok(())
}
#[allow(dead_code)]
pub(crate) async fn get<T>(&self, path: &str) -> CrabResult<T>
where
T: serde::de::DeserializeOwned,
{
let url = self.url(path);
let request = self.client.get(&url);
let request = self.apply_auth(request, "GET", &url);
let response = request.send().await?;
Self::check_rate_limit(&response)?;
let bytes = response.bytes().await?;
response::parse_response_bytes(&bytes)
}
#[allow(dead_code)]
pub(crate) async fn get_with_query<T, Q>(&self, path: &str, query: &Q) -> CrabResult<T>
where
T: serde::de::DeserializeOwned,
Q: serde::Serialize,
{
let query_string = serde_urlencoded::to_string(query).map_err(|e| {
CrabError::InvalidResponse(format!("Failed to serialize query params: {}", e))
})?;
let base_url = self.url(path);
let full_url = if query_string.is_empty() {
base_url.clone()
} else {
format!("{}?{}", base_url, query_string)
};
let request = self.client.get(&base_url).query(query);
let request = self.apply_auth(request, "GET", &full_url);
let response = request.send().await?;
Self::check_rate_limit(&response)?;
let bytes = response.bytes().await?;
response::parse_response_bytes(&bytes)
}
pub(crate) async fn delete_with_query<T, Q>(&self, path: &str, query: &Q) -> CrabResult<T>
where
T: serde::de::DeserializeOwned,
Q: serde::Serialize,
{
let query_string = serde_urlencoded::to_string(query).map_err(|e| {
CrabError::InvalidResponse(format!("Failed to serialize query params: {}", e))
})?;
let base_url = self.url(path);
let full_url = if query_string.is_empty() {
base_url.clone()
} else {
format!("{}?{}", base_url, query_string)
};
let request = self.client.delete(&base_url).query(query);
let request = self.apply_auth(request, "DELETE", &full_url);
let response = request.send().await?;
Self::check_rate_limit(&response)?;
let bytes = response.bytes().await?;
response::parse_response_bytes(&bytes)
}
pub(crate) async fn get_avatar(&self, path: &str) -> CrabResult<AvatarResponse> {
let url = self.url(path);
let request = self.client.get(&url);
let request = self.apply_auth(request, "GET", &url);
let response = request.send().await?;
Self::check_rate_limit(&response)?;
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let bytes = response.bytes().await?;
if &content_type == "image/png" {
Ok(AvatarResponse::ImageData(bytes.to_vec()))
} else {
let response: AvatarResponseUrl = response::parse_response_bytes(&bytes)?;
Ok(AvatarResponse::ImageUrl {
avatar_url: response.avatar_url,
})
}
}
#[allow(dead_code)]
pub(crate) async fn post<T, B>(&self, path: &str, body: &B) -> CrabResult<T>
where
T: serde::de::DeserializeOwned,
B: serde::Serialize,
{
let url = self.url(path);
let request = self.client.post(&url).json(body);
let request = self.apply_auth(request, "POST", &url);
let response = request.send().await?;
Self::check_rate_limit(&response)?;
let bytes = response.bytes().await?;
response::parse_response_bytes(&bytes)
}
#[allow(dead_code)]
pub(crate) async fn put<T, B>(&self, path: &str, body: &B) -> CrabResult<T>
where
T: serde::de::DeserializeOwned,
B: serde::Serialize,
{
let url = self.url(path);
let request = self.client.put(&url).json(body);
let request = self.apply_auth(request, "PUT", &url);
let response = request.send().await?;
Self::check_rate_limit(&response)?;
let bytes = response.bytes().await?;
response::parse_response_bytes(&bytes)
}
async fn send_multipart<T, B>(
&self,
method: reqwest::Method,
path: &str,
body: &B,
media_sources: std::collections::HashMap<String, media::MediaSource>,
) -> CrabResult<T>
where
T: serde::de::DeserializeOwned,
B: serde::Serialize,
{
let url = self.url(path);
let mut form = reqwest::multipart::Form::new();
let json_str = serde_json::to_string(body).map_err(|e| {
CrabError::InvalidResponse(format!("Failed to serialize request body: {}", e))
})?;
form = form.text("json", json_str);
for (identifier, source) in media_sources {
let bytes = source.read_bytes().map_err(|e| {
CrabError::InvalidResponse(format!("Failed to read media source: {}", e))
})?;
let mut part =
reqwest::multipart::Part::bytes(bytes).file_name(source.filename().to_string());
if let Some(mime_type) = source.mime_type() {
part = part.mime_str(mime_type).map_err(|e| {
CrabError::InvalidResponse(format!("Invalid MIME type '{}': {}", mime_type, e))
})?;
}
form = form.part(identifier, part);
}
let request = self.client.request(method.clone(), &url).multipart(form);
let request = self.apply_auth(request, method.as_str(), &url);
let response = request.send().await?;
Self::check_rate_limit(&response)?;
let bytes = response.bytes().await?;
response::parse_response_bytes(&bytes)
}
pub(crate) async fn post_multipart<T, B>(
&self,
path: &str,
body: &B,
media_sources: std::collections::HashMap<String, media::MediaSource>,
) -> CrabResult<T>
where
T: serde::de::DeserializeOwned,
B: serde::Serialize,
{
self.send_multipart(reqwest::Method::POST, path, body, media_sources)
.await
}
pub(crate) async fn put_multipart<T, B>(
&self,
path: &str,
body: &B,
media_sources: std::collections::HashMap<String, media::MediaSource>,
) -> CrabResult<T>
where
T: serde::de::DeserializeOwned,
B: serde::Serialize,
{
self.send_multipart(reqwest::Method::PUT, path, body, media_sources)
.await
}
#[allow(dead_code)]
pub(crate) async fn delete<T>(&self, path: &str) -> CrabResult<T>
where
T: serde::de::DeserializeOwned,
{
let url = self.url(path);
let request = self.client.delete(&url);
let request = self.apply_auth(request, "DELETE", &url);
let response = request.send().await?;
Self::check_rate_limit(&response)?;
let bytes = response.bytes().await?;
response::parse_response_bytes(&bytes)
}
}
pub struct CrabraveBuilder {
consumer_key: Option<String>,
consumer_secret: Option<String>,
access_token: Option<String>,
access_token_secret: Option<String>,
user_agent: Option<String>,
base_url: Option<String>,
}
impl CrabraveBuilder {
fn new() -> Self {
Self {
consumer_key: None,
consumer_secret: None,
access_token: None,
access_token_secret: None,
user_agent: None,
base_url: None,
}
}
pub fn consumer_key(mut self, key: impl Into<String>) -> Self {
self.consumer_key = Some(key.into());
self
}
pub fn consumer_secret(mut self, secret: impl Into<String>) -> Self {
self.consumer_secret = Some(secret.into());
self
}
pub fn access_token(mut self, token: impl Into<String>) -> Self {
self.access_token = Some(token.into());
self
}
pub fn access_token_secret(mut self, secret: impl Into<String>) -> Self {
self.access_token_secret = Some(secret.into());
self
}
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = Some(user_agent.into());
self
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
pub fn build(self) -> CrabResult<Crabrave> {
let consumer_key = self.consumer_key.ok_or(CrabError::MissingConsumerKey)?;
let auth = if let Some(access_token_secret) = self.access_token_secret {
let consumer_secret = self
.consumer_secret
.ok_or(CrabError::MissingConsumerSecret)?;
let access_token = self.access_token.ok_or(CrabError::MissingAccessToken)?;
Auth::OAuth1 {
consumer_key,
consumer_secret,
access_token,
access_token_secret,
}
} else if let Some(access_token) = self.access_token {
let consumer_secret = self
.consumer_secret
.ok_or(CrabError::MissingConsumerSecret)?;
Auth::OAuth2 {
consumer_key,
consumer_secret,
access_token,
}
} else {
Auth::ApiKey { consumer_key }
};
let mut headers = HeaderMap::new();
let user_agent = self
.user_agent
.unwrap_or_else(|| DEFAULT_USER_AGENT.to_string());
headers.insert(
USER_AGENT,
HeaderValue::from_str(&user_agent).map_err(|_| CrabError::InvalidUserAgent)?,
);
let client = reqwest::Client::builder()
.default_headers(headers)
.build()
.map_err(CrabError::HttpClient)?;
let base_url = self.base_url.unwrap_or_else(|| BASE_API_URL.to_string());
Ok(Crabrave {
client,
base_url,
auth: Arc::new(auth),
})
}
}
pub(crate) fn empty_object_as_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum EmptyOrValue<T> {
Value(T),
Empty {},
}
match EmptyOrValue::deserialize(deserializer)? {
EmptyOrValue::Value(v) => Ok(Some(v)),
EmptyOrValue::Empty {} => Ok(None),
}
}
pub(crate) fn deserialize_content_blocks<'de, D>(
deserializer: D,
) -> Result<Vec<npf::ContentBlock>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum ContentOrString {
Blocks(Vec<npf::ContentBlock>),
#[allow(dead_code)]
Html(String),
}
match ContentOrString::deserialize(deserializer)? {
ContentOrString::Blocks(blocks) => Ok(blocks),
ContentOrString::Html(_) => Ok(Vec::new()), }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder_oauth2() {
let result = Crabrave::builder()
.consumer_key("test_key")
.consumer_secret("test_secret")
.access_token("test_token")
.build();
assert!(result.is_ok());
let crab = result.unwrap();
assert_eq!(crab.base_url(), BASE_API_URL);
}
#[test]
fn test_builder_oauth1() {
let result = Crabrave::builder()
.consumer_key("test_key")
.consumer_secret("test_secret")
.access_token("test_token")
.access_token_secret("test_token_secret")
.build();
assert!(result.is_ok());
}
#[test]
fn test_builder_api_key_only() {
let result = Crabrave::builder().consumer_key("test_key").build();
assert!(result.is_ok());
}
#[test]
fn test_builder_missing_consumer_key() {
let result = Crabrave::builder()
.consumer_secret("test_secret")
.access_token("test_token")
.build();
assert!(matches!(result, Err(CrabError::MissingConsumerKey)));
}
#[test]
fn test_builder_custom_base_url() {
let custom_url = "https://test.example.com/api";
let crab = Crabrave::builder()
.consumer_key("test_key")
.base_url(custom_url)
.build()
.unwrap();
assert_eq!(crab.base_url(), custom_url);
}
#[test]
fn test_builder_custom_user_agent() {
let result = Crabrave::builder()
.consumer_key("test_key")
.user_agent("CustomAgent/1.0")
.build();
assert!(result.is_ok());
}
}