diff --git a/crates/services/gifbox/src/api.rs b/crates/services/gifbox/src/api.rs deleted file mode 100644 index 36a29757..00000000 --- a/crates/services/gifbox/src/api.rs +++ /dev/null @@ -1,74 +0,0 @@ -use axum::{ - extract::{Query, State}, - routing::get, - Json, Router, -}; -use revolt_database::User; -use revolt_result::{create_error, Result}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -use crate::{ - tenor, - types, - AppState, -}; - -pub async fn router() -> Router { - Router::new() - .route("/", get(root)) - .route("/search", get(search)) -} - -/// Successful root response -#[derive(Serialize, Debug, ToSchema)] -pub struct RootResponse { - message: &'static str, - version: &'static str, -} - -/// Capture crate version from Cargo -static CRATE_VERSION: &str = env!("CARGO_PKG_VERSION"); - -/// Root response from service -#[utoipa::path( - get, - path = "/", - responses( - (status = 200, description = "Echo response", body = RootResponse) - ) -)] -async fn root() -> Json { - Json(RootResponse { - message: "Gifbox lives on!", - version: CRATE_VERSION, - }) -} - -#[derive(Deserialize)] -struct SearchQueryParams { - pub query: String, - pub locale: String, - pub position: Option, -} - -#[utoipa::path( - get, - path = "/search", - responses( - (status = 200, description = "Search results", body = SearchResponse) - ) -)] -async fn search( - _user: User, - Query(params): Query, - State(tenor): State, -) -> Result> { - // Todo - tenor - .search(¶ms.query, ¶ms.locale, params.position.as_deref()) - .await - .map_err(|_| create_error!(InternalError)) - .map(|results| results.as_ref().clone().into()) - .map(Json) -} diff --git a/crates/services/gifbox/src/main.rs b/crates/services/gifbox/src/main.rs index a3339078..86588518 100644 --- a/crates/services/gifbox/src/main.rs +++ b/crates/services/gifbox/src/main.rs @@ -6,14 +6,14 @@ use revolt_config::config; use revolt_database::{Database, DatabaseInfo}; use tokio::net::TcpListener; use utoipa::{ - openapi::security::{Http, HttpAuthScheme, SecurityScheme}, + openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, Modify, OpenApi, }; use utoipa_scalar::{Scalar, Servable as ScalarServable}; use crate::tenor::Tenor; -mod api; +mod routes; mod tenor; mod types; @@ -35,6 +35,26 @@ impl FromRef for Tenor { } } +struct TokenAddon; + +impl Modify for TokenAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + let components = openapi.components.get_or_insert_default(); + + components.add_security_scheme( + "User Token", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new( + "X-Session-Token".to_string(), + ))), + ); + + components.add_security_scheme( + "Bot Token", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("X-Bot-Token".to_string()))), + ); + } +} + #[tokio::main] async fn main() -> Result<(), std::io::Error> { // Configure logging and environment @@ -43,36 +63,28 @@ async fn main() -> Result<(), std::io::Error> { // Configure API schema #[derive(OpenApi)] #[openapi( - modifiers(&SecurityAddon), + modifiers(&TokenAddon), paths( - api::root, - api::search, + routes::categories::categories, + routes::root::root, + routes::search::search, + routes::trending::trending, + ), + tags( + (name = "Misc", description = "Misc routes for microservice."), + (name = "GIFs", description = "All routes for requesting GIFs from tenor.") ), components( schemas( revolt_result::Error, revolt_result::ErrorType, - types::SearchResponse, - types::MediaObject, types::MediaResult, + types::MediaObject, ) - ) + ), )] struct ApiDoc; - struct SecurityAddon; - - impl Modify for SecurityAddon { - fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { - if let Some(components) = openapi.components.as_mut() { - components.add_security_scheme( - "api_key", - SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)), - ) - } - } - } - let config = config().await; let state = AppState { database: DatabaseInfo::Auto @@ -85,7 +97,7 @@ async fn main() -> Result<(), std::io::Error> { // Configure Axum and router let app = Router::new() .merge(Scalar::with_url("/scalar", ApiDoc::openapi())) - .nest("/", api::router().await) + .nest("/", routes::router()) .with_state(state); // Configure TCP listener and bind diff --git a/crates/services/gifbox/src/routes/categories.rs b/crates/services/gifbox/src/routes/categories.rs new file mode 100644 index 00000000..ae85126d --- /dev/null +++ b/crates/services/gifbox/src/routes/categories.rs @@ -0,0 +1,47 @@ +use axum::{ + extract::{Query, State}, + Json, +}; +use revolt_database::User; +use revolt_result::{create_error, Result}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::{tenor, types}; + +#[derive(Deserialize, IntoParams)] +pub struct CategoriesQueryParams { + #[param(example = "en_US")] + pub locale: String, +} + +/// Trending GIF categories +#[utoipa::path( + get, + path = "/categories", + tag = "GIFs", + security(("User Token" = []), ("Bot Token" = [])), + params(CategoriesQueryParams), + responses( + (status = 200, description = "Categories results", body = inline(Vec)) + ) +)] +pub async fn categories( + _user: User, + Query(params): Query, + State(tenor): State, +) -> Result>> { + tenor + .categories(¶ms.locale) + .await + .map_err(|_| create_error!(InternalError)) + .map(|results| { + (*results) + .clone() + .tags + .into_iter() + .map(|cat| cat.into()) + .collect() + }) + .map(Json) +} diff --git a/crates/services/gifbox/src/routes/mod.rs b/crates/services/gifbox/src/routes/mod.rs new file mode 100644 index 00000000..efee317f --- /dev/null +++ b/crates/services/gifbox/src/routes/mod.rs @@ -0,0 +1,15 @@ +use crate::AppState; +use axum::routing::{get, Router}; + +pub mod categories; +pub mod root; +pub mod search; +pub mod trending; + +pub fn router() -> Router { + Router::new() + .route("/", get(root::root)) + .route("/categories", get(categories::categories)) + .route("/search", get(search::search)) + .route("/trending", get(trending::trending)) +} diff --git a/crates/services/gifbox/src/routes/root.rs b/crates/services/gifbox/src/routes/root.rs new file mode 100644 index 00000000..36acb877 --- /dev/null +++ b/crates/services/gifbox/src/routes/root.rs @@ -0,0 +1,22 @@ +use axum::Json; + +use crate::types; + +/// Capture crate version from Cargo +static CRATE_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Root response from service +#[utoipa::path( + get, + path = "/", + tag = "Misc", + responses( + (status = 200, description = "Root response", body = inline(types::RootResponse)) + ) +)] +pub async fn root() -> Json> { + Json(types::RootResponse { + message: "Gifbox lives on!", + version: CRATE_VERSION, + }) +} \ No newline at end of file diff --git a/crates/services/gifbox/src/routes/search.rs b/crates/services/gifbox/src/routes/search.rs new file mode 100644 index 00000000..ec0e6c6e --- /dev/null +++ b/crates/services/gifbox/src/routes/search.rs @@ -0,0 +1,51 @@ +use axum::{ + extract::{Query, State}, + Json, +}; +use revolt_database::User; +use revolt_result::{create_error, Result}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::{tenor, types}; + +#[derive(Deserialize, IntoParams)] +pub struct SearchQueryParams { + #[param(example = "Wave")] + pub query: String, + #[param(example = "en_US")] + pub locale: String, + pub limit: Option, + pub is_category: Option, + pub position: Option, +} + +/// Searches for GIFs with a query +#[utoipa::path( + get, + path = "/search", + tag = "GIFs", + security(("User Token" = []), ("Bot Token" = [])), + params(SearchQueryParams), + responses( + (status = 200, description = "Search results", body = inline(types::PaginatedMediaResponse)) + ) +)] +pub async fn search( + _user: User, + Query(params): Query, + State(tenor): State, +) -> Result> { + tenor + .search( + ¶ms.query, + ¶ms.locale, + params.limit.unwrap_or(50), + params.is_category.unwrap_or_default(), + params.position.as_deref().unwrap_or_default(), + ) + .await + .map_err(|_| create_error!(InternalError)) + .map(|results| results.as_ref().clone().into()) + .map(Json) +} diff --git a/crates/services/gifbox/src/routes/trending.rs b/crates/services/gifbox/src/routes/trending.rs new file mode 100644 index 00000000..f08ed242 --- /dev/null +++ b/crates/services/gifbox/src/routes/trending.rs @@ -0,0 +1,45 @@ +use axum::{ + extract::{Query, State}, + Json, +}; +use revolt_database::User; +use revolt_result::{create_error, Result}; +use serde::{Deserialize}; +use utoipa::{IntoParams}; + +use crate::{ + tenor, + types, +}; + +#[derive(Deserialize, IntoParams)] +pub struct TrendingQueryParams { + #[param(example = "en_US")] + pub locale: String, + pub limit: Option, + pub position: Option +} + +/// Trending GIFs +#[utoipa::path( + get, + path = "/featured", + tag = "GIFs", + security(("User Token" = []), ("Bot Token" = [])), + params(TrendingQueryParams), + responses( + (status = 200, description = "Trending results", body = inline(types::PaginatedMediaResponse)) + ) +)] +pub async fn trending( + _user: User, + Query(params): Query, + State(tenor): State, +) -> Result> { + tenor + .featured(¶ms.locale, params.limit.unwrap_or(50), params.position.as_deref().unwrap_or_default()) + .await + .map_err(|_| create_error!(InternalError)) + .map(|results| results.as_ref().clone().into()) + .map(Json) +} diff --git a/crates/services/gifbox/src/tenor/mod.rs b/crates/services/gifbox/src/tenor/mod.rs index 2ff3785c..da9b97aa 100644 --- a/crates/services/gifbox/src/tenor/mod.rs +++ b/crates/services/gifbox/src/tenor/mod.rs @@ -3,6 +3,7 @@ use std::{sync::Arc, time::Duration}; use lru_time_cache::LruCache; use reqwest::Client; use revolt_coalesced::{CoalescionService, CoalescionServiceConfig}; +use serde::de::DeserializeOwned; use tokio::sync::RwLock; use urlencoding::encode as url_encode; @@ -19,34 +20,89 @@ pub enum TenorError { pub struct Tenor { pub key: Arc, pub client: Client, - pub coalescion: CoalescionService, TenorError>>, - pub cache: Arc>>>, + + pub cache: Arc>>>, + pub cache_coalescion: + CoalescionService, TenorError>>, + + pub categories: Arc>>>, + pub categories_coalescion: + CoalescionService, TenorError>>, + + pub featured: Arc>>>, + pub featured_coalescion: + CoalescionService, TenorError>>, } impl Tenor { pub fn new(key: &str) -> Self { - // 1 hour, 1k queries - let cache = LruCache::with_expiry_duration_and_capacity(Duration::from_secs(60 * 60), 1000); - Self { key: Arc::from(key), client: Client::new(), - coalescion: CoalescionService::from_config(CoalescionServiceConfig { + + // 1 hour, 1k requests + cache: Arc::new(RwLock::new(LruCache::with_expiry_duration_and_capacity( + Duration::from_secs(60 * 60), + 1000, + ))), + cache_coalescion: CoalescionService::from_config(CoalescionServiceConfig { + max_concurrent: Some(100), + queue_requests: true, + max_queue: None, + }), + + // 1 day, 1k requests + categories: Arc::new(RwLock::new(LruCache::with_expiry_duration_and_capacity( + Duration::from_secs(60 * 60 * 24), + 1000, + ))), + categories_coalescion: CoalescionService::from_config(CoalescionServiceConfig { + max_concurrent: Some(100), + queue_requests: true, + max_queue: None, + }), + + // 1 day, 1k requests + featured: Arc::new(RwLock::new(LruCache::with_expiry_duration_and_capacity( + Duration::from_secs(60 * 60 * 24), + 1000, + ))), + featured_coalescion: CoalescionService::from_config(CoalescionServiceConfig { max_concurrent: Some(100), queue_requests: true, max_queue: None, }), - cache: Arc::new(RwLock::new(cache)), } } + pub async fn request(&self, path: String) -> Result, TenorError> { + let response = self + .client + .get(format!("{TENOR_API_BASE_URL}{path}")) + .send() + .await + .inspect_err(|e| { + revolt_config::capture_error(e); + }) + .map_err(|_| TenorError::HttpError)?; + + let text = response.text().await.map_err(|e| { + println!("{e:?}"); + TenorError::HttpError + })?; + + Ok(Arc::new(serde_json::from_str(&text).unwrap())) + } + pub async fn search( &self, query: &str, locale: &str, - position: Option<&str>, - ) -> Result, TenorError> { - let unique_key = format!("{query}:{locale}:{position:?}"); + limit: u32, + is_category: bool, + position: &str, + ) -> Result, TenorError> { + let unique_key = format!("s:{query}:{locale}:{is_category}:{position}"); if self.cache.read().await.contains_key(&unique_key) { if let Some(response) = self.cache.write().await.get(&unique_key) { @@ -54,32 +110,24 @@ impl Tenor { } } - let res = self.coalescion.execute(unique_key.clone(), || { - let client = self.client.clone(); + let res = self.cache_coalescion.execute(unique_key.clone(), || { + let mut path = format!( + "/search?key={}&q={}&client_key=Gifbox&media_filter=webm,tinywebm&locale={}&contentfilter=high&limit={limit}", + &self.key, + url_encode(query), + url_encode(locale), + ); - async move { - let mut url = format!( - "{TENOR_API_BASE_URL}/search?key={}&q={}&client_key=Gifbox&media_filter=webm,tinywebm&locale={}&contentfilter=medium&limit=1", - &self.key, - url_encode(query), - url_encode(locale), - ); + if !position.is_empty() { + path.push_str("&pos="); + path.push_str(position); + }; - if let Some(position) = position { - url.push_str("&pos="); - url.push_str(position); - }; - - let response = client.get(url) - .send() - .await - .inspect_err(|e| { revolt_config::capture_error(e); }) - .map_err(|_| TenorError::HttpError)?; - - let text = response.text().await.map_err(|e| { println!("{e:?}"); TenorError::HttpError })?; - - Ok(Arc::new(serde_json::from_str(&text).unwrap())) + if is_category { + path.push_str("&component=categories"); } + + self.request(path) }) .await .unwrap(); @@ -90,4 +138,81 @@ impl Tenor { (*res).clone() } + + pub async fn categories( + &self, + locale: &str, + ) -> Result, TenorError> { + let unique_key = format!("c-{locale}"); + + if self.categories.read().await.contains_key(&unique_key) { + if let Some(response) = self.categories.write().await.get(&unique_key) { + return Ok(response.clone()); + } + } + + let res = self + .categories_coalescion + .execute(unique_key.clone(), || { + let path = format!( + "/categories?key={}&client_key=Gifbox&locale={}&contentfilter=high", + &self.key, + url_encode(locale), + ); + + self.request(path) + }) + .await + .unwrap(); + + if let Ok(resp) = &*res { + self.categories + .write() + .await + .insert(unique_key, resp.clone()); + } + + (*res).clone() + } + + pub async fn featured( + &self, + locale: &str, + limit: u32, + position: &str, + ) -> Result, TenorError> { + let unique_key = format!("f-{locale}-{limit}-{position}"); + + if self.categories.read().await.contains_key(&unique_key) { + if let Some(response) = self.featured.write().await.get(&unique_key) { + return Ok(response.clone()); + } + } + + let res = self.featured_coalescion.execute(unique_key.clone(), || { + let mut path = format!( + "/featured?key={}&client_key=Gifbox&media_filter=webm,tinywebm&locale={}&contentfilter=high&limit={limit}", + &self.key, + url_encode(locale), + ); + + if !position.is_empty() { + path.push_str("&pos="); + path.push_str(position); + }; + + self.request(path) + }) + .await + .unwrap(); + + if let Ok(resp) = &*res { + self.featured + .write() + .await + .insert(unique_key, resp.clone()); + } + + (*res).clone() + } } diff --git a/crates/services/gifbox/src/tenor/types.rs b/crates/services/gifbox/src/tenor/types.rs index b4ad4a08..8165e4b4 100644 --- a/crates/services/gifbox/src/tenor/types.rs +++ b/crates/services/gifbox/src/tenor/types.rs @@ -3,8 +3,8 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] -pub struct SearchResponse { - pub results: Vec, +pub struct PaginatedMediaResponse { + pub results: Vec, pub next: String, } @@ -17,7 +17,7 @@ pub struct MediaObject { } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] -pub struct MediaResult { +pub struct MediaResponse { pub created: f64, #[serde(default)] pub hasaudio: bool, @@ -33,3 +33,17 @@ pub struct MediaResult { pub bg_color: Option, pub url: String, } + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct CategoriesResponse { + pub locale: String, + pub tags: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct CategoryResponse { + pub searchterm: String, + pub path: String, + pub image: String, + pub name: String, +} diff --git a/crates/services/gifbox/src/types.rs b/crates/services/gifbox/src/types.rs index d4267602..1ee07e8a 100644 --- a/crates/services/gifbox/src/types.rs +++ b/crates/services/gifbox/src/types.rs @@ -1,13 +1,20 @@ use std::collections::HashMap; +use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use serde::{Serialize, Deserialize}; use crate::tenor::types; -#[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq)] +/// Successful root response +#[derive(Serialize, Debug, ToSchema)] +pub struct RootResponse<'a> { + pub message: &'a str, + pub version: &'a str, +} + /// Response containing the current results and the id of the next result for pagination. -pub struct SearchResponse { +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq)] +pub struct PaginatedMediaResponse { /// Current gif results. pub results: Vec, #[serde(skip_serializing_if = "Option::is_none")] @@ -15,19 +22,19 @@ pub struct SearchResponse { pub next: Option, } -#[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq)] /// Indivual gif result. +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq)] pub struct MediaResult { /// Unique Tenor id. pub id: String, /// Mapping of each file format and url of the file. pub media_formats: HashMap, /// Public Tenor web url for the gif. - pub url: String + pub url: String, } -#[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq)] /// Represents the gif in a certain file format. +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq)] pub struct MediaObject { /// File url of the gif in a certain format. pub url: String, @@ -35,26 +42,42 @@ pub struct MediaObject { pub dimensions: Vec, } +/// Represents a GIF category +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq)] +pub struct CategoryResponse { + /// Category title + pub title: String, + /// Category image + pub image: String, +} -impl From for SearchResponse { - fn from(value: types::SearchResponse) -> Self { +impl From for PaginatedMediaResponse { + fn from(value: types::PaginatedMediaResponse) -> Self { Self { - results: value.results.into_iter().map(|result| result.into()).collect(), + results: value + .results + .into_iter() + .map(|result| result.into()) + .collect(), next: if value.next.is_empty() { None } else { Some(value.next) - } + }, } } } -impl From for MediaResult { - fn from(value: types::MediaResult) -> Self { +impl From for MediaResult { + fn from(value: types::MediaResponse) -> Self { Self { id: value.id, - media_formats: value.media_formats.into_iter().map(|(k, v)| (k, v.into())).collect(), - url: value.url + media_formats: value + .media_formats + .into_iter() + .map(|(k, v)| (k, v.into())) + .collect(), + url: value.url, } } } @@ -63,7 +86,16 @@ impl From for MediaObject { fn from(value: types::MediaObject) -> Self { Self { url: value.url, - dimensions: value.dims + dimensions: value.dims, } } -} \ No newline at end of file +} + +impl From for CategoryResponse { + fn from(value: types::CategoryResponse) -> Self { + Self { + title: value.searchterm, + image: value.image, + } + } +}