From 03443be19cbca07b3414ffde5d7d3bd904c00a8b Mon Sep 17 00:00:00 2001 From: kasisnu <5044120+kasisnu@users.noreply.github.com> Date: Wed, 11 Oct 2023 16:52:37 +0530 Subject: [PATCH] inventory list voices with pagination --- .../inventory/list_voices_query_builder.rs | 365 ++++++++++++++++++ .../queries/voice_designer/inventory/mod.rs | 1 + .../src/queries/voice_designer/mod.rs | 1 + .../voices/list_available_voices.rs | 148 ++++++- 4 files changed, 511 insertions(+), 4 deletions(-) create mode 100644 crates/lib/mysql_queries/src/queries/voice_designer/inventory/list_voices_query_builder.rs create mode 100644 crates/lib/mysql_queries/src/queries/voice_designer/inventory/mod.rs diff --git a/crates/lib/mysql_queries/src/queries/voice_designer/inventory/list_voices_query_builder.rs b/crates/lib/mysql_queries/src/queries/voice_designer/inventory/list_voices_query_builder.rs new file mode 100644 index 0000000000..a8cbadb892 --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/voice_designer/inventory/list_voices_query_builder.rs @@ -0,0 +1,365 @@ +#![forbid(unused_imports)] +#![forbid(unused_mut)] +#![forbid(unused_variables)] + +use chrono::{DateTime, Utc}; +use sqlx::MySqlPool; + +use config::shared_constants::DEFAULT_MYSQL_QUERY_RESULT_PAGE_SIZE; +use enums::common::visibility::Visibility; +use errors::AnyhowResult; + + +#[derive(Serialize)] +pub struct ZsVoiceListPage { + pub voices: Vec, + pub sort_ascending: bool, + + pub first_id: Option, + + pub last_id: Option, +} + +#[derive(Serialize)] +pub struct ZsVoiceRecordForList { + pub voice_token: String, + pub title: String, + pub creator_set_visibility: Visibility, + pub ietf_language_tag: String, + pub ietf_primary_language_subtag: String, + pub creator_user_token: String, + pub creator_username: String, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub struct ListVoicesQueryBuilder { + scope_creator_username: Option, + include_user_hidden: bool, + include_mod_deleted_results: bool, + include_user_deleted_results: bool, + sort_ascending: bool, + offset: Option, + limit: u16, + cursor_is_reversed: bool, +} + +impl ListVoicesQueryBuilder { + pub fn new() -> Self { + Self { + scope_creator_username: None, + include_user_hidden: false, + include_mod_deleted_results: false, + include_user_deleted_results: false, + sort_ascending: false, + offset: None, + limit: DEFAULT_MYSQL_QUERY_RESULT_PAGE_SIZE, + cursor_is_reversed: false, + } + } + + pub fn scope_creator_username(mut self, scope_creator_username: Option<&str>) -> Self { + self.scope_creator_username = scope_creator_username.map(|u| u.to_string()); + self + } + + pub fn include_user_hidden(mut self, include_user_hidden: bool) -> Self { + self.include_user_hidden = include_user_hidden; + self + } + + pub fn include_mod_deleted_results(mut self, include_mod_deleted_results: bool) -> Self { + self.include_mod_deleted_results = include_mod_deleted_results; + self + } + + pub fn include_user_deleted_results(mut self, include_user_deleted_results: bool) -> Self { + self.include_user_deleted_results = include_user_deleted_results; + self + } + + pub fn sort_ascending(mut self, sort_ascending: bool) -> Self { + self.sort_ascending = sort_ascending; + self + } + + pub fn offset(mut self, offset: Option) -> Self { + self.offset = offset; + self + } + + pub fn limit(mut self, limit: u16) -> Self { + self.limit = limit; + self + } + + pub fn cursor_is_reversed(mut self, cursor_is_reversed: bool) -> Self { + self.cursor_is_reversed = cursor_is_reversed; + self + } + + pub async fn perform_internal_query( + &self, + mysql_pool: &MySqlPool + ) -> AnyhowResult> { + let query = self.build_query_string(); + let mut query = sqlx::query_as::<_, RawInternalVoiceRecordForList>(&query); + + // NB: The following bindings must match the order of the query builder !! + + if let Some(offset) = self.offset { + query = query.bind(offset); + } + + if let Some(username) = self.scope_creator_username.as_deref() { + query = query.bind(username); + } + + query = query.bind(self.limit); + + let mut results = query.fetch_all(mysql_pool) + .await?; + + if self.cursor_is_reversed { + results.reverse() + } + + Ok(results) + } + + + + pub async fn perform_query_for_page( + &self, + mysql_pool: &MySqlPool + ) -> AnyhowResult { + let internal_voices = self.perform_internal_query(mysql_pool).await?; + + let first_id = internal_voices.first() + .map(|raw_result| raw_result.voice_id); + + let last_id = internal_voices.last() + .map(|raw_result| raw_result.voice_id); + + let voices = internal_voices.into_iter().map( + |v| { + ZsVoiceRecordForList { + voice_token: v.token.to_string(), + title: v.title, + creator_set_visibility: Visibility::from_str(&v.creator_set_visibility).unwrap_or(Visibility::Public), + ietf_language_tag: v.ietf_language_tag, + ietf_primary_language_subtag: v.ietf_primary_language_subtag, + creator_user_token: v.creator_user_token, + creator_username: v.creator_username, + created_at: v.created_at, + updated_at: v.updated_at, + } + }) + .collect::>(); + + Ok(ZsVoiceListPage { + voices, + sort_ascending: self.sort_ascending, + first_id, + last_id, + }) + } + + pub fn build_query_string(&self) -> String { + let mut query = r#" + SELECT + zs_voices.id as voice_id, + zs_voices.token, + zs_voices.title, + zs_voices.ietf_language_tag, + zs_voices.ietf_primary_language_subtag, + users.token as creator_user_token, + users.username as creator_username, + zs_voices.creator_set_visibility, + zs_voices.created_at, + zs_voices.updated_at + FROM zs_voices + JOIN users + ON users.token = zs_voices.maybe_creator_user_token + "#.to_string(); + + query.push_str(&self.build_predicates()); + query + } + + pub fn build_predicates(&self) -> String { + // NB: Reverse cursors require us to invert the sort direction. + let mut sort_ascending = self.sort_ascending; + + let mut first_predicate_added = false; + + let mut query = "".to_string(); + + if let Some(_offset) = self.offset { + if !first_predicate_added { + query.push_str(" WHERE"); + first_predicate_added = true; + } else { + query.push_str(" AND"); + } + + if sort_ascending { + if self.cursor_is_reversed { + // NB: We're searching backwards. + query.push_str(" zs_voices.id < ?"); + sort_ascending = !sort_ascending; + } else { + query.push_str(" zs_voices.id > ?"); + } + } else { + if self.cursor_is_reversed { + // NB: We're searching backwards. + query.push_str(" zs_voices.id > ?"); + sort_ascending = !sort_ascending; + } else { + query.push_str(" zs_voices.id < ?"); + } + } + } + + if let Some(_username) = self.scope_creator_username.as_deref() { + if !first_predicate_added { + query.push_str(" WHERE users.username = ?"); + first_predicate_added = true; + } else { + query.push_str(" AND users.username = ?"); + } + } + + if !self.include_user_hidden { + if !first_predicate_added { + query.push_str(" WHERE zs_voices.creator_set_visibility = 'public'"); + first_predicate_added = true; + } else { + query.push_str(" AND zs_voices.creator_set_visibility = 'public'"); + } + } + + if !self.include_mod_deleted_results { + if !first_predicate_added { + query.push_str(" WHERE zs_voices.mod_deleted_at IS NULL"); + first_predicate_added = true; + } else { + query.push_str(" AND zs_voices.mod_deleted_at IS NULL"); + } + } + + if !self.include_user_deleted_results { + if !first_predicate_added { + query.push_str(" WHERE zs_voices.user_deleted_at IS NULL"); + first_predicate_added = true; + } else { + query.push_str(" AND zs_voices.user_deleted_at IS NULL"); + } + } + + if sort_ascending { + query.push_str(" ORDER BY zs_voices.id ASC"); + } else { + query.push_str(" ORDER BY zs_voices.id DESC"); + } + + query.push_str(" LIMIT ?"); + + query + } +} + +#[derive(sqlx::FromRow)] +pub struct RawInternalVoiceRecordForList { + pub voice_id: i64, + pub token: String, + pub title: String, + pub ietf_language_tag: String, + pub ietf_primary_language_subtag: String, + pub creator_user_token: String, + pub creator_username: String, + pub creator_set_visibility: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[cfg(test)] +mod tests { + use crate::queries::voice_designer::inventory::list_voices_query_builder::ListVoicesQueryBuilder; + + #[test] + fn predicates_without_scoping() { + let query_builder = ListVoicesQueryBuilder::new(); + + assert_eq!(&query_builder.build_predicates(), + " WHERE zs_voices.creator_set_visibility = 'public' \ + AND zs_voices.mod_deleted_at IS NULL \ + AND zs_voices.user_deleted_at IS NULL \ + ORDER BY zs_voices.id DESC \ + LIMIT ?"); + } + + #[test] + fn predicates_scoped_to_user() { + let query_builder = ListVoicesQueryBuilder::new() + .scope_creator_username(Some("echelon")); + + assert_eq!(&query_builder.build_predicates(), + " WHERE users.username = ? \ + AND zs_voices.creator_set_visibility = 'public' \ + AND zs_voices.mod_deleted_at IS NULL \ + AND zs_voices.user_deleted_at IS NULL \ + ORDER BY zs_voices.id DESC \ + LIMIT ?"); + } + + #[test] + fn predicates_including_user_hidden() { + let query_builder = ListVoicesQueryBuilder::new() + .include_user_hidden(true); + + assert_eq!(&query_builder.build_predicates(), + " WHERE zs_voices.mod_deleted_at IS NULL \ + AND zs_voices.user_deleted_at IS NULL \ + ORDER BY zs_voices.id DESC \ + LIMIT ?"); + } + + #[test] + fn predicates_including_mod_deleted() { + let query_builder = ListVoicesQueryBuilder::new() + .include_mod_deleted_results(true); + + assert_eq!(&query_builder.build_predicates(), + " WHERE zs_voices.creator_set_visibility = 'public' \ + AND zs_voices.user_deleted_at IS NULL \ + ORDER BY zs_voices.id DESC \ + LIMIT ?"); + } + + #[test] + fn predicates_including_user_deleted() { + let query_builder = ListVoicesQueryBuilder::new() + .include_user_deleted_results(true); + + assert_eq!(&query_builder.build_predicates(), + " WHERE zs_voices.creator_set_visibility = 'public' \ + AND zs_voices.mod_deleted_at IS NULL \ + ORDER BY zs_voices.id DESC \ + LIMIT ?"); + } + + #[test] + fn predicates_including_mod_deleted_and_user_deleted() { + let query_builder = ListVoicesQueryBuilder::new() + .include_mod_deleted_results(true) + .include_user_deleted_results(true); + + assert_eq!(&query_builder.build_predicates(), + " WHERE zs_voices.creator_set_visibility = 'public' \ + ORDER BY zs_voices.id DESC \ + LIMIT ?"); + } +} \ No newline at end of file diff --git a/crates/lib/mysql_queries/src/queries/voice_designer/inventory/mod.rs b/crates/lib/mysql_queries/src/queries/voice_designer/inventory/mod.rs new file mode 100644 index 0000000000..8bdc5dbb20 --- /dev/null +++ b/crates/lib/mysql_queries/src/queries/voice_designer/inventory/mod.rs @@ -0,0 +1 @@ +pub mod list_voices_query_builder; \ No newline at end of file diff --git a/crates/lib/mysql_queries/src/queries/voice_designer/mod.rs b/crates/lib/mysql_queries/src/queries/voice_designer/mod.rs index 70b4a115a5..2d51d3134c 100644 --- a/crates/lib/mysql_queries/src/queries/voice_designer/mod.rs +++ b/crates/lib/mysql_queries/src/queries/voice_designer/mod.rs @@ -1,3 +1,4 @@ pub mod datasets; pub mod voice_samples; pub mod voices; +pub mod inventory; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/list_available_voices.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/list_available_voices.rs index 661bf2e1b5..b2216813b5 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/list_available_voices.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/voice_designer/voices/list_available_voices.rs @@ -7,10 +7,150 @@ use actix_web::error::ResponseError; use actix_web::http::StatusCode; use log::{info, warn}; +use mysql_queries::queries::voice_designer::inventory::list_voices_query_builder::{ListVoicesQueryBuilder, ZsVoiceRecordForList}; +use crate::server_state::ServerState; - -pub async fn list_available_voices() -> HttpResponse { - // Implementation for listing available voices (paginated) - HttpResponse::Ok().json("List of available voices") +#[derive(Deserialize)] +pub struct ListZsVoicesQuery { + pub sort_ascending: Option, + pub limit: Option, + pub cursor: Option, + pub cursor_is_reversed: Option, } + +#[derive(Serialize)] +pub struct ListZsVoicesSuccessResponse { + pub success: bool, + pub voices: Vec, + pub cursor_next: Option, + pub cursor_previous: Option +} + +#[derive(Debug)] +pub enum ListZsVoicesError { + NotAuthorized, + ServerError, +} + +impl std::fmt::Display for ListZsVoicesError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +impl ResponseError for ListZsVoicesError { + fn status_code(&self) -> StatusCode { + match *self { + ListZsVoicesError::NotAuthorized => StatusCode::UNAUTHORIZED, + ListZsVoicesError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +pub async fn list_available_voices( + http_request: HttpRequest, + query: web::Query, + server_state: web::Data> +) -> Result { + + 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); + ListZsVoicesError::ServerError + })?; + + let mut is_mod = false; + let user_session = match maybe_user_session { + Some(session) => { + is_mod = session.can_ban_users; + session + } + None => { + info!("not logged in"); + return Err(ListZsVoicesError::NotAuthorized); + } + }; + + + let limit = query.limit.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); + ListZsVoicesError::ServerError + })?; + Some(cursor) + } else { + None + }; + + let include_user_hidden = is_mod; + + + let mut query_builder = ListVoicesQueryBuilder::new() + .sort_ascending(sort_ascending) + .scope_creator_username(None) + .include_user_hidden(include_user_hidden) + .include_user_deleted_results(is_mod) + .include_mod_deleted_results(is_mod) + .limit(limit) + .cursor_is_reversed(cursor_is_reversed) + .offset(cursor); + + let query_results = query_builder.perform_query_for_page(&server_state.mysql_pool).await; + + + let voices_page = match query_results { + Ok(results) => results, + Err(e) => { + warn!("Query error: {:?}", e); + return Err(ListZsVoicesError::ServerError); + } + }; + + let cursor_next = if let Some(id) = voices_page.last_id { + let cursor = server_state.sort_key_crypto.encrypt_id(id as u64) + .map_err(|e| { + warn!("crypto error: {:?}", e); + ListZsVoicesError::ServerError + })?; + Some(cursor) + } else { + None + }; + + let cursor_previous = if let Some(id) = voices_page.first_id { + let cursor = server_state.sort_key_crypto.encrypt_id(id as u64) + .map_err(|e| { + warn!("crypto error: {:?}", e); + ListZsVoicesError::ServerError + })?; + Some(cursor) + } else { + None + }; + + let response = ListZsVoicesSuccessResponse { + success: true, + voices: voices_page.voices, + cursor_next, + cursor_previous, + }; + + let body = serde_json::to_string(&response) + .map_err(|e| ListZsVoicesError::ServerError)?; + + Ok(HttpResponse::Ok() + .content_type("application/json") + .body(body)) +} + + +