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 reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
use serde::{Deserialize, Deserializer};
use std::collections::HashSet;
use std::sync::Arc;
use crate::handlers::blog::{AvatarResponse, AvatarResponseUrl};
use crate::oauth::OAuthScope;
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)]
struct Auth {
#[allow(dead_code)] consumer_key: String,
#[allow(dead_code)] consumer_secret: String,
access_token: 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 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).header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", self.auth.access_token),
);
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 base_url = self.url(path);
let request = self.client.get(&base_url).query(query).header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", self.auth.access_token),
);
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 base_url = self.url(path);
let request = self.client.delete(&base_url).query(query).header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", self.auth.access_token),
);
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).header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", self.auth.access_token),
);
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).header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", self.auth.access_token),
);
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).header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", self.auth.access_token),
);
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)
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", self.auth.access_token),
);
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).header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", self.auth.access_token),
);
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>,
user_agent: Option<String>,
base_url: Option<String>,
scopes: HashSet<OAuthScope>,
}
impl CrabraveBuilder {
fn new() -> Self {
let mut scopes = HashSet::new();
scopes.insert(OAuthScope::Basic);
Self {
consumer_key: None,
consumer_secret: None,
access_token: None,
user_agent: None,
base_url: None,
scopes,
}
}
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 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 add_scope(mut self, scope: OAuthScope) -> Self {
self.scopes.insert(scope);
self
}
pub fn add_scopes<S: IntoIterator<Item = OAuthScope>>(mut self, scope: S) -> Self {
for scope in scope.into_iter() {
self.scopes.insert(scope);
}
self
}
pub fn build(self) -> CrabResult<Crabrave> {
let consumer_key = self.consumer_key.ok_or(CrabError::MissingConsumerKey)?;
let consumer_secret = self
.consumer_secret
.ok_or(CrabError::MissingConsumerSecret)?;
let access_token = self.access_token.ok_or(CrabError::MissingAccessToken)?;
let auth = Auth {
consumer_key,
consumer_secret,
access_token,
};
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>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value {
serde_json::Value::String(_) => Ok(Vec::new()), serde_json::Value::Array(arr) => arr
.into_iter()
.enumerate()
.map(|(i, v)| {
let block_type = v
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("unknown")
.to_owned();
serde_json::from_value(v).map_err(|e| {
serde::de::Error::custom(format!(
"content block index {i} (type: {block_type}): {e}"
))
})
})
.collect(),
other => Err(serde::de::Error::custom(format!(
"expected array or string for content, got {}",
kind_of(&other),
))),
}
}
pub(crate) fn kind_of(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
#[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_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_user_agent() {
let result = Crabrave::builder()
.consumer_key("test_key")
.consumer_secret("test_secret")
.access_token("test_access_token")
.user_agent("CustomAgent/1.0")
.build();
assert!(result.is_ok());
}
}