Implement API pagination for API v2
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
e367c341b7
commit
c4a8251147
8 changed files with 240 additions and 32 deletions
144
spec/v2.json
144
spec/v2.json
|
@ -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": {
|
||||||
|
|
|
@ -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),
|
||||||
|
|
27
src/sql.rs
27
src/sql.rs
|
@ -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)
|
||||||
|
|
|
@ -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()),
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
},
|
||||||
_ => (),
|
_ => (),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue