feat: trending and categories routes

This commit is contained in:
Zomatree
2025-09-11 14:18:28 +01:00
committed by Angelo Kontaxis
parent a92152d86d
commit 5885e067a6
10 changed files with 437 additions and 148 deletions

View File

@@ -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<AppState> {
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<RootResponse> {
Json(RootResponse {
message: "Gifbox lives on!",
version: CRATE_VERSION,
})
}
#[derive(Deserialize)]
struct SearchQueryParams {
pub query: String,
pub locale: String,
pub position: Option<String>,
}
#[utoipa::path(
get,
path = "/search",
responses(
(status = 200, description = "Search results", body = SearchResponse)
)
)]
async fn search(
_user: User,
Query(params): Query<SearchQueryParams>,
State(tenor): State<tenor::Tenor>,
) -> Result<Json<types::SearchResponse>> {
// Todo
tenor
.search(&params.query, &params.locale, params.position.as_deref())
.await
.map_err(|_| create_error!(InternalError))
.map(|results| results.as_ref().clone().into())
.map(Json)
}

View File

@@ -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<AppState> 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

View File

@@ -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<types::CategoryResponse>))
)
)]
pub async fn categories(
_user: User,
Query(params): Query<CategoriesQueryParams>,
State(tenor): State<tenor::Tenor>,
) -> Result<Json<Vec<types::CategoryResponse>>> {
tenor
.categories(&params.locale)
.await
.map_err(|_| create_error!(InternalError))
.map(|results| {
(*results)
.clone()
.tags
.into_iter()
.map(|cat| cat.into())
.collect()
})
.map(Json)
}

View File

@@ -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<AppState> {
Router::new()
.route("/", get(root::root))
.route("/categories", get(categories::categories))
.route("/search", get(search::search))
.route("/trending", get(trending::trending))
}

View File

@@ -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<types::RootResponse<'static>> {
Json(types::RootResponse {
message: "Gifbox lives on!",
version: CRATE_VERSION,
})
}

View File

@@ -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<u32>,
pub is_category: Option<bool>,
pub position: Option<String>,
}
/// 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<SearchQueryParams>,
State(tenor): State<tenor::Tenor>,
) -> Result<Json<types::PaginatedMediaResponse>> {
tenor
.search(
&params.query,
&params.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)
}

View File

@@ -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<u32>,
pub position: Option<String>
}
/// 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<TrendingQueryParams>,
State(tenor): State<tenor::Tenor>,
) -> Result<Json<types::PaginatedMediaResponse>> {
tenor
.featured(&params.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)
}

View File

@@ -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<str>,
pub client: Client,
pub coalescion: CoalescionService<String, Result<Arc<types::SearchResponse>, TenorError>>,
pub cache: Arc<RwLock<LruCache<String, Arc<types::SearchResponse>>>>,
pub cache: Arc<RwLock<LruCache<String, Arc<types::PaginatedMediaResponse>>>>,
pub cache_coalescion:
CoalescionService<String, Result<Arc<types::PaginatedMediaResponse>, TenorError>>,
pub categories: Arc<RwLock<LruCache<String, Arc<types::CategoriesResponse>>>>,
pub categories_coalescion:
CoalescionService<String, Result<Arc<types::CategoriesResponse>, TenorError>>,
pub featured: Arc<RwLock<LruCache<String, Arc<types::PaginatedMediaResponse>>>>,
pub featured_coalescion:
CoalescionService<String, Result<Arc<types::PaginatedMediaResponse>, 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<T: DeserializeOwned>(&self, path: String) -> Result<Arc<T>, 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<Arc<types::SearchResponse>, TenorError> {
let unique_key = format!("{query}:{locale}:{position:?}");
limit: u32,
is_category: bool,
position: &str,
) -> Result<Arc<types::PaginatedMediaResponse>, 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<Arc<types::CategoriesResponse>, 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<Arc<types::PaginatedMediaResponse>, 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()
}
}

View File

@@ -3,8 +3,8 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct SearchResponse {
pub results: Vec<MediaResult>,
pub struct PaginatedMediaResponse {
pub results: Vec<MediaResponse>,
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<String>,
pub url: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct CategoriesResponse {
pub locale: String,
pub tags: Vec<CategoryResponse>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct CategoryResponse {
pub searchterm: String,
pub path: String,
pub image: String,
pub name: String,
}

View File

@@ -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<MediaResult>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -15,19 +22,19 @@ pub struct SearchResponse {
pub next: Option<String>,
}
#[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<String, MediaObject>,
/// 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<u64>,
}
/// 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<types::SearchResponse> for SearchResponse {
fn from(value: types::SearchResponse) -> Self {
impl From<types::PaginatedMediaResponse> 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<types::MediaResult> for MediaResult {
fn from(value: types::MediaResult) -> Self {
impl From<types::MediaResponse> 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<types::MediaObject> for MediaObject {
fn from(value: types::MediaObject) -> Self {
Self {
url: value.url,
dimensions: value.dims
dimensions: value.dims,
}
}
}
}
impl From<types::CategoryResponse> for CategoryResponse {
fn from(value: types::CategoryResponse) -> Self {
Self {
title: value.searchterm,
image: value.image,
}
}
}