Implement API pagination for API v2
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Timo Ley 2022-07-21 12:18:25 +02:00
parent e367c341b7
commit c4a8251147
8 changed files with 240 additions and 32 deletions

View file

@ -44,7 +44,7 @@
{ {
"name": "limit", "name": "limit",
"in": "query", "in": "query",
"description": "How many memes should be returned at maximum", "description": "How many memes should be returned at maximum (-1 for no limit)",
"required": false, "required": false,
"schema": { "schema": {
"type": "string" "type": "string"
@ -87,7 +87,7 @@
} }
}, },
"post": { "post": {
"summary": "Upload an image or video to JensMemes", "summary": "Upload an image or video to JensMemes (WIP)",
"security": [ "security": [
{ {
"discord": [] "discord": []
@ -239,16 +239,36 @@
} }
} }
}, },
"/memes/stream": { "/memes/count": {
"get": { "get": {
"summary": "Returns a stream of new uploaded memes", "summary": "Gives the total number of memes",
"parameters": [
{
"name": "category",
"in": "query",
"description": "Only count memes from this category ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "user",
"in": "query",
"description": "Only count memes from this user",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "Stream of memes", "description": "Amount of memes",
"content": { "content": {
"application/x-json-stream": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/Meme" "$ref": "#/components/schemas/Count"
} }
} }
} }
@ -402,6 +422,103 @@
} }
} }
}, },
"/users/{id}/memes" : {
"get": {
"summary": "Get all memes of a user",
"parameters": [
{
"name": "limit",
"in": "query",
"description": "How many memes should be returned at maximum (-1 for no limit)",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "after",
"in": "query",
"description": "ID of the meme after which the returned memes should start",
"required": false,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Meme list response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Meme"
}
}
}
}
},
"default": {
"description": "Some error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/users/{id}/memes/{filename}": {
"get": {
"summary": "Gives a specific meme from a user by filename",
"parameters": [
{
"name": "id",
"in": "path",
"description": "The ID of the user",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "filename",
"in": "path",
"description": "The filename of the meme",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Meme response of this meme",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Meme"
}
}
}
},
"default": {
"description": "Some error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/clips": { "/clips": {
"get": { "get": {
"summary": "WIP", "summary": "WIP",
@ -475,12 +592,15 @@
"Meme": { "Meme": {
"type": "object", "type": "object",
"properties": { "properties": {
"link": { "filename": {
"type": "string" "type": "string"
}, },
"id": { "id": {
"type": "integer" "type": "integer"
}, },
"ipfs": {
"type": "string"
},
"category": { "category": {
"type": "string" "type": "string"
}, },
@ -517,6 +637,14 @@
} }
} }
}, },
"Count": {
"type": "object",
"properties": {
"count": {
"type": "integer"
}
}
},
"Clip": { "Clip": {
"type": "object", "type": "object",
"properties": { "properties": {

View file

@ -26,6 +26,11 @@ pub struct User {
pub dayuploads: i32, pub dayuploads: i32,
} }
#[derive(Serialize)]
pub struct Count {
pub count: i64,
}
pub enum UserIdentifier { pub enum UserIdentifier {
Id(String), Id(String),
Token(String), Token(String),

View file

@ -1,5 +1,5 @@
use crate::ipfs::IPFSFile; use crate::ipfs::IPFSFile;
use crate::models::{Category, Meme, MemeOptions, User, UserIdentifier}; use crate::models::{Category, Count, Meme, MemeOptions, User, UserIdentifier};
use crate::JMServiceInner; use crate::JMServiceInner;
use sqlx::mysql::MySqlRow; use sqlx::mysql::MySqlRow;
use sqlx::{MySqlPool, Result, Row}; use sqlx::{MySqlPool, Result, Row};
@ -46,12 +46,13 @@ impl JMServiceInner {
} }
pub async fn get_memes(&self, filter: MemeOptions) -> Result<Vec<Meme>> { pub async fn get_memes(&self, filter: MemeOptions) -> Result<Vec<Meme>> {
let q: Vec<Meme> = sqlx::query("SELECT memes.id, user, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts, cid FROM memes, users WHERE memes.user = users.id AND (category LIKE ? AND name LIKE ? AND filename LIKE ? AND memes.user LIKE ? AND memes.id > ?) ORDER BY memes.id") let q: Vec<Meme> = sqlx::query("SELECT memes.id, user, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts, cid FROM memes, users WHERE memes.user = users.id AND (category LIKE ? AND name LIKE ? AND filename LIKE ? AND memes.user LIKE ? AND memes.id > ?) ORDER BY memes.id LIMIT ?")
.bind(filter.category.unwrap_or_else(|| String::from("%"))) .bind(filter.category.unwrap_or_else(|| String::from("%")))
.bind(format!("%{}%", filter.username.unwrap_or_else(String::new))) .bind(format!("%{}%", filter.username.unwrap_or_default()))
.bind(format!("%{}%", filter.search.unwrap_or_else(String::new))) .bind(format!("%{}%", filter.search.unwrap_or_default()))
.bind(filter.user_id.unwrap_or_else(|| String::from("%"))) .bind(filter.user_id.unwrap_or_else(|| String::from("%")))
.bind(filter.after.unwrap_or(0)) .bind(filter.after.unwrap_or(0))
.bind(filter.limit.unwrap_or(100))
.map(|row: MySqlRow| Meme { .map(|row: MySqlRow| Meme {
id: row.get("id"), id: row.get("id"),
filename: row.get("filename"), filename: row.get("filename"),
@ -68,8 +69,8 @@ impl JMServiceInner {
pub async fn get_random_meme(&self, filter: MemeOptions) -> Result<Meme> { pub async fn get_random_meme(&self, filter: MemeOptions) -> Result<Meme> {
let q: Meme = sqlx::query("SELECT memes.id, user, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts, cid FROM memes, users WHERE memes.user = users.id AND (category LIKE ? AND name LIKE ? AND filename LIKE ? AND memes.user LIKE ? AND memes.id > ?) ORDER BY RAND() LIMIT 1") let q: Meme = sqlx::query("SELECT memes.id, user, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts, cid FROM memes, users WHERE memes.user = users.id AND (category LIKE ? AND name LIKE ? AND filename LIKE ? AND memes.user LIKE ? AND memes.id > ?) ORDER BY RAND() LIMIT 1")
.bind(filter.category.unwrap_or_else(|| String::from("%"))) .bind(filter.category.unwrap_or_else(|| String::from("%")))
.bind(format!("%{}%", filter.username.unwrap_or_else(String::new))) .bind(format!("%{}%", filter.username.unwrap_or_default()))
.bind(format!("%{}%", filter.search.unwrap_or_else(String::new))) .bind(format!("%{}%", filter.search.unwrap_or_default()))
.bind(filter.user_id.unwrap_or_else(|| String::from("%"))) .bind(filter.user_id.unwrap_or_else(|| String::from("%")))
.bind(filter.after.unwrap_or(0)) .bind(filter.after.unwrap_or(0))
.map(|row: MySqlRow| Meme { .map(|row: MySqlRow| Meme {
@ -85,6 +86,20 @@ impl JMServiceInner {
Ok(q) Ok(q)
} }
pub async fn count_memes(&self, filter: MemeOptions) -> Result<Count> {
let q: Count = sqlx::query(
"SELECT COUNT(id) AS count FROM memes WHERE category LIKE ? AND user LIKE ?",
)
.bind(filter.category.unwrap_or_else(|| String::from("%")))
.bind(filter.user_id.unwrap_or_else(|| String::from("%")))
.map(|row: MySqlRow| Count {
count: row.get("count"),
})
.fetch_one(&self.db_pool)
.await?;
Ok(q)
}
pub async fn get_user_meme(&self, user_id: String, filename: String) -> Result<Option<Meme>> { pub async fn get_user_meme(&self, user_id: String, filename: String) -> Result<Option<Meme>> {
let q: Option<Meme> = sqlx::query("SELECT memes.id, user, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts, cid FROM memes, users WHERE memes.user = users.id AND memes.user = ? AND filename = ? ORDER BY memes.id DESC") let q: Option<Meme> = sqlx::query("SELECT memes.id, user, filename, category, name, UNIX_TIMESTAMP(timestamp) AS ts, cid FROM memes, users WHERE memes.user = users.id AND memes.user = ? AND filename = ? ORDER BY memes.id DESC")
.bind(user_id) .bind(user_id)

View file

@ -41,7 +41,7 @@ impl IntoResponse for APIError {
APIError::NotFound(err) => ErrorResponse::new(StatusCode::NOT_FOUND, Some(err)), APIError::NotFound(err) => ErrorResponse::new(StatusCode::NOT_FOUND, Some(err)),
APIError::Internal(err) => { APIError::Internal(err) => {
ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, Some(err)) ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, Some(err))
} },
APIError::Service(err) => ErrorResponse::new( APIError::Service(err) => ErrorResponse::new(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Some(err.get_response_message()), Some(err.get_response_message()),

View file

@ -139,7 +139,7 @@ impl From<MemeFilter> for MemeOptions {
user_id: None, user_id: None,
username: filter.user, username: filter.user,
search: filter.search, search: filter.search,
limit: None, limit: Some(-1),
after: None, after: None,
} }
} }

View file

@ -137,7 +137,7 @@ async fn upload(
.to_string(); .to_string();
let file = service.ipfs_add(field.bytes().await?, filename).await?; let file = service.ipfs_add(field.bytes().await?, filename).await?;
files.push(file); files.push(file);
} },
_ => (), _ => (),
} }
} }

View file

@ -1,5 +1,5 @@
use crate::models::{Meme, User}; use crate::models::{Meme, MemeOptions, User};
use serde::Serialize; use serde::{Deserialize, Serialize};
#[derive(Serialize)] #[derive(Serialize)]
pub struct V2Meme { pub struct V2Meme {
@ -18,6 +18,15 @@ pub struct V2User {
pub dayuploads: i32, pub dayuploads: i32,
} }
#[derive(Deserialize)]
pub struct MemeFilterQuery {
pub category: Option<String>,
pub user: Option<String>,
pub search: Option<String>,
pub limit: Option<i32>,
pub after: Option<i32>,
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct CDNEntry { pub struct CDNEntry {
pub directories: Vec<String>, pub directories: Vec<String>,
@ -52,3 +61,16 @@ impl From<User> for V2User {
} }
} }
} }
impl From<MemeFilterQuery> for MemeOptions {
fn from(query: MemeFilterQuery) -> Self {
Self {
category: query.category,
user_id: query.user,
username: None,
search: query.search,
limit: query.limit,
after: query.after,
}
}
}

View file

@ -12,7 +12,7 @@ use crate::{
JMService, JMService,
}; };
use super::models::{V2Meme, V2User}; use super::models::{MemeFilterQuery, V2Meme, V2User};
async fn get_meme( async fn get_meme(
Path(meme_id): Path<i32>, Path(meme_id): Path<i32>,
@ -27,11 +27,12 @@ async fn get_meme(
} }
async fn get_memes( async fn get_memes(
Query(filter): Query<MemeFilterQuery>,
Extension(service): Extension<JMService>, Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> { ) -> Result<impl IntoResponse, APIError> {
Ok(Json( Ok(Json(
service service
.get_memes(MemeOptions::empty()) .get_memes(filter.into())
.await? .await?
.into_iter() .into_iter()
.map(V2Meme::from) .map(V2Meme::from)
@ -39,6 +40,22 @@ async fn get_memes(
)) ))
} }
async fn get_random_meme(
Query(filter): Query<MemeFilterQuery>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
Ok(Json(V2Meme::from(
service.get_random_meme(filter.into()).await?,
)))
}
async fn count_memes(
Query(filter): Query<MemeFilterQuery>,
Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> {
Ok(Json(service.count_memes(filter.into()).await?))
}
async fn get_category( async fn get_category(
Path(category_id): Path<String>, Path(category_id): Path<String>,
Extension(service): Extension<JMService>, Extension(service): Extension<JMService>,
@ -80,6 +97,7 @@ async fn get_users(
} }
async fn get_user_memes( async fn get_user_memes(
Query(filter): Query<MemeFilterQuery>,
Path(user_id): Path<String>, Path(user_id): Path<String>,
Extension(service): Extension<JMService>, Extension(service): Extension<JMService>,
) -> Result<impl IntoResponse, APIError> { ) -> Result<impl IntoResponse, APIError> {
@ -90,8 +108,8 @@ async fn get_user_memes(
user_id: Some(user_id), user_id: Some(user_id),
username: None, username: None,
search: None, search: None,
limit: None, limit: filter.limit,
after: None, after: filter.after,
}) })
.await? .await?
.into_iter() .into_iter()
@ -113,15 +131,35 @@ async fn get_user_meme(
))) )))
} }
pub fn routes() -> Router<BoxRoute> { fn meme_routes() -> Router<BoxRoute> {
Router::new() Router::new()
.route("/memes", get(get_memes)) .route("/", get(get_memes))
.route("/memes/:meme_id", get(get_meme)) .route("/:meme_id", get(get_meme))
.route("/categories", get(get_categories)) .route("/random", get(get_random_meme))
.route("/categories/:category_id", get(get_category)) .route("/count", get(count_memes))
.route("/users", get(get_users)) .boxed()
.route("/users/:user_id", get(get_user)) }
.route("/users/:user_id/memes", get(get_user_memes))
.route("/users/:user_id/memes/:filename", get(get_user_meme)) fn category_routes() -> Router<BoxRoute> {
Router::new()
.route("/", get(get_categories))
.route("/:category_id", get(get_category))
.boxed()
}
fn user_routes() -> Router<BoxRoute> {
Router::new()
.route("/", get(get_users))
.route("/:user_id", get(get_user))
.route("/:user_id/memes", get(get_user_memes))
.route("/:user_id/memes/:filename", get(get_user_meme))
.boxed()
}
pub fn routes() -> Router<BoxRoute> {
Router::new()
.nest("/memes", meme_routes())
.nest("/categories", category_routes())
.nest("/users", user_routes())
.boxed() .boxed()
} }