Merge pull request #242 from storytold/dec-21-mithoo

media files pagination fix
This commit is contained in:
Brandon Thomas authored and GitHub committed 2023-12-22 14:11:41 -05:00
commit aa0632bd41
9 files changed
+261 -166

No files matched your search

@@ -50,6 +50,7 @@ pub enum UserBookmarkEntityType {
// TODO(bt, 2023-01-17): This desperately needs MySQL integration tests!
impl_enum_display_and_debug_using_to_str!(UserBookmarkEntityType);
impl_mysql_enum_coders!(UserBookmarkEntityType);
impl_mysql_from_row!(UserBookmarkEntityType);
/// NB: Legacy API for older code.
impl UserBookmarkEntityType {
@@ -62,6 +62,7 @@ pub struct ListMediaFilesArgs<'a> {
pub maybe_filter_media_type: Option<MediaFileType>,
pub maybe_offset: Option<usize>,
pub cursor_is_reversed: bool,
pub sort_ascending: bool,
pub view_as: ViewAs,
pub mysql_pool: &'a MySqlPool,
}
@@ -73,6 +74,7 @@ pub async fn list_media_files(args: ListMediaFilesArgs<'_>) -> AnyhowResult<Medi
args.limit,
args.maybe_offset,
args.cursor_is_reversed,
args.sort_ascending,
args.view_as,
);
@@ -124,9 +126,11 @@ fn query_builder<'a>(
limit: usize,
maybe_offset: Option<usize>,
cursor_is_reversed: bool,
sort_ascending: bool,
view_as: ViewAs,
) -> QueryBuilder<'a, MySql> {
let mut sort_ascending = sort_ascending;
// NB: Query cannot be statically checked by sqlx
let mut query_builder: QueryBuilder<MySql> = QueryBuilder::new(
r#"
@@ -192,17 +196,29 @@ WHERE
}
if let Some(offset) = maybe_offset {
if cursor_is_reversed {
query_builder.push(format!(" AND m.id < {offset} "));
if sort_ascending {
if cursor_is_reversed {
// NB: We're searching backwards.
query_builder.push(" AND m.id < ");
sort_ascending = !sort_ascending;
} else {
query_builder.push(" AND m.id > ");
}
} else {
query_builder.push(format!(" AND m.id > {offset} "));
if cursor_is_reversed {
// NB: We're searching backwards.
query_builder.push(" AND m.id > ");
sort_ascending = !sort_ascending;
} else {
query_builder.push(" AND m.id < ");
}
}
query_builder.push_bind(offset as i64);
}
query_builder.push(" GROUP BY m.id");
if cursor_is_reversed {
if sort_ascending {
query_builder.push(" ORDER BY m.id ASC ");
} else {
query_builder.push(" ORDER BY m.id DESC ");
@@ -1,28 +1,49 @@
use anyhow::anyhow;
use log::warn;
use sqlx::MySqlPool;
use sqlx::{MySql, MySqlPool, QueryBuilder};
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::by_table::user_bookmarks::user_bookmark_entity_type::UserBookmarkEntityType;
use enums::common::visibility::Visibility;
use errors::AnyhowResult;
use tokens::tokens::user_bookmarks::UserBookmarkToken;
use crate::queries::media_files::list::list_media_files_for_user::{MediaFileListItem, MediaFileListPage, ViewAs};
use crate::queries::user_bookmarks::list_user_bookmarks_result::RawUserBookmarkRecord;
use crate::queries::user_bookmarks::list_user_bookmarks_result::UserBookmark;
pub async fn list_user_bookmarks(
username: &str,
mysql_pool: &MySqlPool
) -> AnyhowResult<Vec<UserBookmark>> {
/// TODO(bt,2023-11-21): Maybe this query can use a switch
/// See: https://stackoverflow.com/questions/2761574/mysql-use-case-else-value-as-join-parameter
let maybe_results= sqlx::query_as!(
RawUserBookmarkRecord,
r#"
SELECT
f.token as `token: tokens::tokens::user_bookmarks::UserBookmarkToken`,
f.entity_type as `entity_type: enums::by_table::user_bookmarks::user_bookmark_entity_type::UserBookmarkEntityType`,
pub struct UserBookmarkListPage {
pub results: Vec<UserBookmark>,
pub sort_ascending: bool,
pub current_page: usize,
pub total_page_count: usize,
}
pub struct ListUserBookmarksForUserArgs<'a> {
pub username: &'a str,
pub maybe_filter_entity_type: Option<UserBookmarkEntityType>,
pub page_size: usize,
pub page_index: usize,
pub sort_ascending: bool,
pub mysql_pool: &'a MySqlPool,
}
fn select_total_count_field() -> String {
r#"
COUNT(f.id) AS total_count
"#
.to_string()
}
fn select_result_fields() -> String {
r#"
f.token,
f.entity_type,
f.entity_token,
f.user_token as `user_token: tokens::tokens::users::UserToken`,
f.user_token,
u.username,
u.display_name as user_display_name,
u.email_gravatar_hash as user_gravatar_hash,
@@ -30,8 +51,8 @@ SELECT
f.updated_at,
f.deleted_at,
media_files.media_type as `maybe_media_file_type: enums::by_table::media_files::media_file_type::MediaFileType`,
media_files.origin_product_category as `maybe_media_file_origin_category: enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory`,
media_files.media_type as maybe_media_file_type,
media_files.origin_product_category as maybe_media_file_origin_category,
model_weights.title as maybe_descriptive_text_model_weight_title,
tts_models.title as maybe_descriptive_text_tts_model_title,
@@ -39,7 +60,25 @@ SELECT
users.display_name as maybe_descriptive_text_user_display_name,
voice_conversion_models.title as maybe_descriptive_text_voice_conversion_model_title,
zs_voices.title as maybe_descriptive_text_zs_voice_title
"#.to_string()
}
fn query_builder<'a>(
maybe_filter_entity_type: Option<UserBookmarkEntityType>,
maybe_username: Option<&'a str>,
enforce_limits: bool,
page_index: usize,
page_size: usize,
sort_ascending: bool,
select_fields: &'a str,
) -> QueryBuilder<'a, MySql> {
let mut first_predicate_added = false;
// NB: Query cannot be statically checked by sqlx
let mut query_builder: QueryBuilder<MySql> = QueryBuilder::new(
format!(r#"
SELECT
{select_fields}
FROM
user_bookmarks AS f
JOIN users AS u
@@ -52,99 +91,91 @@ LEFT OUTER JOIN tts_results ON tts_results.token = f.entity_token
LEFT OUTER JOIN users ON users.token = f.entity_token
LEFT OUTER JOIN voice_conversion_models ON voice_conversion_models.token = f.entity_token
LEFT OUTER JOIN zs_voices ON zs_voices.token = f.entity_token
"#
));
WHERE
u.username = ?
AND f.deleted_at IS NULL
ORDER BY f.id DESC
LIMIT 50
"#,
username
)
.fetch_all(mysql_pool)
.await;
if let Some(username) = maybe_username {
if !first_predicate_added {
query_builder.push(" WHERE ");
first_predicate_added = true;
} else {
query_builder.push(" AND ");
}
query_builder.push(" u.username = ");
query_builder.push_bind(username);
}
match maybe_results {
Err(err) => match err {
sqlx::Error::RowNotFound => Ok(Vec::new()),
_ => {
warn!("list user_bookmarks db error: {:?}", err);
Err(anyhow!("error with query: {:?}", err))
}
},
Ok(results) => Ok(results.into_iter()
.map(|user_bookmark| user_bookmark.into_public_type())
.collect()),
}
if let Some(entity_type) = maybe_filter_entity_type {
if !first_predicate_added {
query_builder.push(" WHERE ");
first_predicate_added = true;
} else {
query_builder.push(" AND ");
}
query_builder.push(" f.entity_type = ");
query_builder.push_bind(entity_type.to_str());
}
query_builder.push(" AND f.deleted_at IS NULL ");
if sort_ascending {
query_builder.push(" ORDER BY f.created_at ASC ");
} else {
query_builder.push(" ORDER BY f.created_at DESC ");
}
if enforce_limits {
let offset = page_index * page_size;
query_builder.push(format!(" LIMIT {page_size} OFFSET {offset} "));
}
query_builder
}
pub async fn list_user_user_bookmarks_by_entity_type(
username: &str,
entity_type: UserBookmarkEntityType,
mysql_pool: &MySqlPool
) -> AnyhowResult<Vec<UserBookmark>> {
pub async fn list_user_bookmarks_by_maybe_entity_type(
args: ListUserBookmarksForUserArgs<'_>
) -> AnyhowResult<UserBookmarkListPage> {
let maybe_results= sqlx::query_as!(
RawUserBookmarkRecord,
r#"
SELECT
f.token as `token: tokens::tokens::user_bookmarks::UserBookmarkToken`,
f.entity_type as `entity_type: enums::by_table::user_bookmarks::user_bookmark_entity_type::UserBookmarkEntityType`,
f.entity_token,
f.user_token as `user_token: tokens::tokens::users::UserToken`,
u.username,
u.display_name as user_display_name,
u.email_gravatar_hash as user_gravatar_hash,
f.created_at,
f.updated_at,
f.deleted_at,
let count_fields = select_total_count_field();
let mut count_query_builder = query_builder(
args.maybe_filter_entity_type,
Some(args.username),
false,
0,
0,
args.sort_ascending,
count_fields.as_str(),
);
media_files.media_type as `maybe_media_file_type: enums::by_table::media_files::media_file_type::MediaFileType`,
media_files.origin_product_category as `maybe_media_file_origin_category: enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory`,
let row_count_query = count_query_builder.build_query_scalar::<i64>();
let row_count_result = row_count_query.fetch_one(args.mysql_pool).await?;
model_weights.title as maybe_descriptive_text_model_weight_title,
tts_models.title as maybe_descriptive_text_tts_model_title,
tts_results.raw_inference_text as maybe_descriptive_text_tts_result_inference_text,
users.display_name as maybe_descriptive_text_user_display_name,
voice_conversion_models.title as maybe_descriptive_text_voice_conversion_model_title,
zs_voices.title as maybe_descriptive_text_zs_voice_title
/// Now fetch the actual results with all the fields
let result_fields = select_result_fields();
let mut query = query_builder(
args.maybe_filter_entity_type,
Some(args.username),
true,
args.page_index,
args.page_size,
args.sort_ascending,
result_fields.as_str(),
);
FROM
user_bookmarks AS f
JOIN users AS u
ON f.user_token = u.token
let query = query.build_query_as::<RawUserBookmarkRecord>();
let results = query.fetch_all(args.mysql_pool).await?;
LEFT OUTER JOIN model_weights ON model_weights.token = f.entity_token
LEFT OUTER JOIN media_files ON media_files.token = f.entity_token
LEFT OUTER JOIN tts_models ON tts_models.token = f.entity_token
LEFT OUTER JOIN tts_results ON tts_results.token = f.entity_token
LEFT OUTER JOIN users ON users.token = f.entity_token
LEFT OUTER JOIN voice_conversion_models ON voice_conversion_models.token = f.entity_token
LEFT OUTER JOIN zs_voices ON zs_voices.token = f.entity_token
let number_of_pages = (row_count_result / args.page_size as i64) as usize;
let results = results.into_iter()
.map(|record| {
record.into_public_type()
})
.collect::<Vec<_>>();
WHERE
u.username = ?
AND f.entity_type = ?
AND f.deleted_at IS NULL
ORDER BY f.id DESC
LIMIT 50
"#,
username,
entity_type
)
.fetch_all(mysql_pool)
.await;
match maybe_results {
Err(err) => match err {
sqlx::Error::RowNotFound => Ok(Vec::new()),
_ => {
warn!("list user_bookmarks db error: {:?}", err);
Err(anyhow!("error with query: {:?}", err))
}
},
Ok(results) => Ok(results.into_iter()
.map(|user_bookmark| user_bookmark.into_public_type())
.collect()),
}
Ok(UserBookmarkListPage {
results,
sort_ascending: args.sort_ascending,
current_page: args.page_index,
total_page_count: number_of_pages,
})
}
@@ -1,10 +1,14 @@
use chrono::{DateTime, Utc};
use sqlx::{FromRow, Row};
use sqlx::mysql::MySqlRow;
use enums::by_table::user_bookmarks::user_bookmark_entity_type::UserBookmarkEntityType;
use enums::by_table::media_files::media_file_origin_category::MediaFileOriginCategory;
use enums::by_table::media_files::media_file_type::MediaFileType;
use enums::traits::mysql_from_row::MySqlFromRow;
use tokens::tokens::user_bookmarks::UserBookmarkToken;
use tokens::tokens::users::UserToken;
use crate::queries::user_bookmarks::user_bookmark_entity_token::UserBookmarkEntityToken;
pub struct UserBookmark {
pub token: UserBookmarkToken,
@@ -85,3 +89,28 @@ impl RawUserBookmarkRecord {
}
}
}
impl FromRow<'_, MySqlRow> for RawUserBookmarkRecord {
fn from_row(row: &MySqlRow) -> Result<Self, sqlx::Error> {
Ok(Self {
token: UserBookmarkToken::new(row.try_get("token")?),
entity_type: UserBookmarkEntityType::try_from_mysql_row(row, "entity_type")?,
entity_token: row.try_get("entity_token")?,
user_token: row.try_get("user_token")?,
username: row.try_get("username")?,
user_display_name: row.try_get("user_display_name")?,
user_gravatar_hash: row.try_get("user_gravatar_hash")?,
created_at: row.try_get("created_at")?,
updated_at: row.try_get("updated_at")?,
deleted_at: row.try_get("deleted_at")?,
maybe_media_file_type: MediaFileType::try_from_mysql_row_nullable(row,"maybe_media_file_type")?,
maybe_media_file_origin_category: MediaFileOriginCategory::try_from_mysql_row_nullable(row,"maybe_media_file_origin_category")?,
maybe_descriptive_text_model_weight_title: row.try_get("maybe_descriptive_text_model_weight_title")?,
maybe_descriptive_text_tts_model_title: row.try_get("maybe_descriptive_text_tts_model_title")?,
maybe_descriptive_text_tts_result_inference_text: row.try_get("maybe_descriptive_text_tts_result_inference_text")?,
maybe_descriptive_text_user_display_name: row.try_get("maybe_descriptive_text_user_display_name")?,
maybe_descriptive_text_voice_conversion_model_title: row.try_get("maybe_descriptive_text_voice_conversion_model_title")?,
maybe_descriptive_text_zs_voice_title: row.try_get("maybe_descriptive_text_zs_voice_title")?,
})
}
}
@@ -152,6 +152,7 @@ pub async fn list_media_files_handler(
maybe_filter_media_type: query.filter_media_type,
maybe_offset: cursor,
cursor_is_reversed,
sort_ascending,
view_as,
mysql_pool: &server_state.mysql_pool,
}).await;
@@ -127,7 +127,6 @@ pub async fn list_media_files_for_user_handler(
};
// TODO(bt,2023-12-04): Enforce real maximums and defaults
let limit = query.page_size.unwrap_or(25);
let sort_ascending = query.sort_ascending.unwrap_or(false);
let page_size = query.page_size.unwrap_or_else(|| 25);
let page_index = query.page_index.unwrap_or_else(|| 0);
@@ -1,7 +1,3 @@
// NB: Incrementally getting rid of build warnings...
#![forbid(unused_imports)]
#![forbid(unused_mut)]
#![forbid(unused_variables)]
use std::fmt;
use std::sync::Arc;
@@ -14,7 +10,7 @@ use chrono::{DateTime, Utc};
use log::warn;
use enums::by_table::user_bookmarks::user_bookmark_entity_type::UserBookmarkEntityType;
use mysql_queries::queries::user_bookmarks::list_user_bookmarks::{list_user_bookmarks, list_user_user_bookmarks_by_entity_type};
use mysql_queries::queries::user_bookmarks::list_user_bookmarks::{ list_user_bookmarks_by_maybe_entity_type};
use tokens::tokens::user_bookmarks::UserBookmarkToken;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
@@ -100,36 +96,17 @@ pub async fn list_user_bookmarks_for_session_handler(
})?
.ok_or(ListUserBookmarksError::NotAuthorizedError)?;
let query_results = match query.maybe_scoped_entity_type {
None => list_user_bookmarks(&user_session.username, &server_state.mysql_pool).await,
Some(entity_type) =>
list_user_user_bookmarks_by_entity_type(&user_session.username, entity_type, &server_state.mysql_pool)
.await,
};
// let query_results = match query.maybe_scoped_entity_type {
// None => list_user_bookmarks(&user_session.username, &server_state.mysql_pool).await,
// Some(entity_type) =>
// list_user_user_bookmarks_by_entity_type(&user_session.username, entity_type, &server_state.mysql_pool)
// .await,
// };
let user_bookmarks = match query_results {
Ok(results) => results,
Err(e) => {
warn!("Query error: {:?}", e);
return Err(ListUserBookmarksError::ServerError);
}
};
let response = ListUserBookmarksSuccessResponse {
success: true,
user_bookmarks: user_bookmarks.into_iter()
.map(|user_bookmark| UserBookmark {
token: user_bookmark.token,
details: UserBookmarkDetails {
entity_type: user_bookmark.entity_type,
entity_token: user_bookmark.entity_token,
maybe_summary_text: None,
maybe_thumbnail_url: None,
},
created_at: user_bookmark.created_at,
updated_at: user_bookmark.updated_at,
})
.collect(),
user_bookmarks: Vec::new()
};
let body = serde_json::to_string(&response)
@@ -15,9 +15,9 @@ use log::warn;
use utoipa::{IntoParams, ToSchema};
use enums::by_table::user_bookmarks::user_bookmark_entity_type::UserBookmarkEntityType;
use mysql_queries::queries::user_bookmarks::list_user_bookmarks::{list_user_bookmarks, list_user_user_bookmarks_by_entity_type};
use mysql_queries::queries::user_bookmarks::list_user_bookmarks::{list_user_bookmarks_by_maybe_entity_type, ListUserBookmarksForUserArgs};
use tokens::tokens::user_bookmarks::UserBookmarkToken;
use crate::http_server::common_responses::pagination_page::PaginationPage;
use crate::http_server::web_utils::response_error_helpers::to_simple_json_error;
use crate::server_state::ServerState;
@@ -28,13 +28,17 @@ pub struct ListUserBookmarksPathInfo {
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListUserBookmarksQueryData {
sort_ascending: Option<bool>,
page_size: Option<usize>,
page_index: Option<usize>,
maybe_scoped_entity_type: Option<UserBookmarkEntityType>,
}
#[derive(Serialize, ToSchema)]
pub struct ListUserBookmarksForUserSuccessResponse {
pub success: bool,
pub user_bookmarks: Vec<UserBookmarkListItem>,
pub results: Vec<UserBookmarkListItem>,
pub pagination: PaginationPage,
}
#[derive(Serialize, ToSchema)]
@@ -101,21 +105,37 @@ responses(
),
)]
pub async fn list_user_bookmarks_for_user_handler(
_http_request: HttpRequest,
http_request: HttpRequest,
path: Path<ListUserBookmarksPathInfo>,
query: Query<ListUserBookmarksQueryData>,
server_state: web::Data<Arc<ServerState>>
) -> Result<HttpResponse, ListUserBookmarksForUserError>
{
let _maybe_user_session = server_state
.session_checker
.maybe_get_user_session(&http_request, &server_state.mysql_pool)
.await
.map_err(|e| {
warn!("Session checker error: {:?}", e);
ListUserBookmarksForUserError::ServerError
})?;
let query_results = match query.maybe_scoped_entity_type {
None => list_user_bookmarks(&path.username, &server_state.mysql_pool).await,
Some(entity_type) =>
list_user_user_bookmarks_by_entity_type(&path.username, entity_type, &server_state.mysql_pool)
.await,
};
let user_bookmarks = match query_results {
let sort_ascending = query.sort_ascending.unwrap_or(false);
let page_size = query.page_size.unwrap_or_else(|| 25);
let page_index = query.page_index.unwrap_or_else(|| 0);
let query_results =
list_user_bookmarks_by_maybe_entity_type(ListUserBookmarksForUserArgs{
username: path.username.as_ref(),
maybe_filter_entity_type: query.maybe_scoped_entity_type.clone(),
sort_ascending,
page_size,
page_index,
mysql_pool: &server_state.mysql_pool,
}).await;
let results_page = match query_results {
Ok(results) => results,
Err(e) => {
warn!("Query error: {:?}", e);
@@ -125,7 +145,7 @@ pub async fn list_user_bookmarks_for_user_handler(
let response = ListUserBookmarksForUserSuccessResponse {
success: true,
user_bookmarks: user_bookmarks.into_iter()
results: results_page.results.into_iter()
.map(|user_bookmark| UserBookmarkListItem {
token: user_bookmark.token,
details: UserBookmarkDetailsForUserList {
@@ -140,6 +160,10 @@ pub async fn list_user_bookmarks_for_user_handler(
updated_at: user_bookmark.updated_at,
})
.collect(),
pagination: PaginationPage{
current: results_page.current_page,
total_page_count: results_page.total_page_count,
}
};
let body = serde_json::to_string(&response)
@@ -148,4 +172,4 @@ pub async fn list_user_bookmarks_for_user_handler(
Ok(HttpResponse::Ok()
.content_type("application/json")
.body(body))
}
}
@@ -6,7 +6,6 @@ use actix_web::http::StatusCode;
use chrono::{DateTime, Utc};
use log::{info, warn};
use rand::Rng;
use utoipa::ToSchema;
use buckets::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::model_weights::{
@@ -21,24 +20,28 @@ use crate::http_server::common_responses::user_details_lite::UserDetailsLight;
use crate::server_state::ServerState;
use utoipa::ToSchema;
use crate::http_server::common_responses::pagination_cursors::PaginationCursors;
#[derive(Deserialize,ToSchema)]
pub struct ListAvailableWeightsQuery {
pub sort_ascending: Option<bool>,
pub page_size: u16,
pub page_index : u16,
pub per_page: Option<u16>,
pub username: Option<String>,
pub weights_type: Option<String>,
pub weights_category: Option<String>,
pub cursor_is_reversed: Option<bool>,
pub cursor: Option<String>,
}
#[derive(Serialize,ToSchema)]
pub struct ListAvailableWeightsSuccessResponse {
pub success: bool,
pub weights: Vec<ModelWeightForList>,
pub page_size: u16,
pub page_index : u16,
pub cursor_next: Option<String>,
pub cursor_previous: Option<String>,
pub results: Vec<ModelWeightForList>,
pub pagination: PaginationCursors,
}
#[derive(Serialize,ToSchema)]
@@ -138,9 +141,21 @@ pub async fn list_available_weights_handler(
}
};
let limit = query.page_size;
let limit = query.per_page.unwrap_or(25);
let sort_ascending = query.sort_ascending.unwrap_or(false);
let cursor_is_reversed = query.cursor_is_reversed.unwrap_or(false);
let cursor = if let Some(cursor) = query.cursor.as_deref() {
let cursor = server_state.sort_key_crypto.decrypt_id(cursor)
.map_err(|e| {
warn!("crypto error: {:?}", e);
ListWeightError::ServerError
})?;
Some(cursor)
} else {
None
};
let include_user_hidden = is_mod;
@@ -175,6 +190,7 @@ pub async fn list_available_weights_handler(
let mut query_builder = ListWeightsQueryBuilder::new()
.sort_ascending(sort_ascending)
.cursor_is_reversed(cursor_is_reversed)
.weights_category(weights_category)
.weights_type(weights_type)
.scope_creator_username(None)
@@ -182,7 +198,7 @@ pub async fn list_available_weights_handler(
.include_user_deleted_results(is_mod)
.include_mod_deleted_results(is_mod)
.limit(limit)
.offset(Some((query.page_index as u64) * (query.page_size as u64)));
.offset(cursor);
let query_results = query_builder.perform_query_for_page(&server_state.mysql_pool).await;
@@ -223,7 +239,7 @@ pub async fn list_available_weights_handler(
// generate parse a response
let response = ListAvailableWeightsSuccessResponse {
success: true,
weights: weights_page.weights.into_iter()
results: weights_page.weights.into_iter()
.map(|weight| {
let maybe_cover_image = weight.maybe_cover_image_public_bucket_hash
@@ -275,10 +291,11 @@ pub async fn list_available_weights_handler(
likes: rng.gen_range(0..1000),
}
}).collect::<Vec<_>>(),
cursor_next,
cursor_previous,
page_index: query.page_index,
page_size: query.page_size
pagination: PaginationCursors {
maybe_next: cursor_next,
maybe_previous: cursor_previous,
cursor_is_reversed,
},
};
let body = serde_json::to_string(&response)