Skip to content

Commit

Permalink
feat: [torrust#796] added pagination
Browse files Browse the repository at this point in the history
  • Loading branch information
mario-nt committed Jan 21, 2025
1 parent ae1e6a9 commit f1259cc
Show file tree
Hide file tree
Showing 9 changed files with 151 additions and 23 deletions.
1 change: 1 addition & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running
let about_service = Arc::new(about::Service::new(authorization_service.clone()));

let listing_service = Arc::new(user::ListingService::new(
configuration.clone(),
user_profile_repository.clone(),
authorization_service.clone(),
))
Expand Down
18 changes: 18 additions & 0 deletions src/config/v2/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,23 @@ pub struct Api {
/// The maximum page size for torrent lists.
#[serde(default = "Api::default_max_torrent_page_size")]
pub max_torrent_page_size: u8,

/// The default page size for user profile lists.
#[serde(default = "Api::default_user_profile_page_size")]
pub default_user_profile_page_size: u8,

/// The maximum page size for user profile lists.
#[serde(default = "Api::default_max_user_profile_page_size")]
pub max_user_profile_page_size: u8,
}

impl Default for Api {
fn default() -> Self {
Self {
default_torrent_page_size: Api::default_default_torrent_page_size(),
max_torrent_page_size: Api::default_max_torrent_page_size(),
default_user_profile_page_size: Api::default_user_profile_page_size(),
max_user_profile_page_size: Api::default_max_user_profile_page_size(),
}
}
}
Expand All @@ -29,4 +39,12 @@ impl Api {
fn default_max_torrent_page_size() -> u8 {
30
}

fn default_user_profile_page_size() -> u8 {
10
}

fn default_max_user_profile_page_size() -> u8 {
100
}
}
6 changes: 3 additions & 3 deletions src/databases/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use url::Url;
use crate::databases::mysql::Mysql;
use crate::databases::sqlite::Sqlite;
use crate::models::category::CategoryId;
use crate::models::response::TorrentsResponse;
use crate::models::response::{TorrentsResponse, UserProfilesResponse};
use crate::models::torrent::{Metadata, TorrentListing};
use crate::models::torrent_file::{DbTorrent, Torrent, TorrentFile};
use crate::models::torrent_tag::{TagId, TorrentTag};
Expand Down Expand Up @@ -143,8 +143,8 @@ pub trait Database: Sync + Send {
/// Get `UserProfile` from `username`.
async fn get_user_profile_from_username(&self, username: &str) -> Result<UserProfile, Error>;

/// Get all user profiles as `Vec<UserProfile>`.
async fn get_user_profiles(&self) -> Result<Vec<UserProfile>, Error>;
/// Get all user profiles in a paginated form as `UserProfilesResponse`.
async fn get_user_profiles_paginated(&self, offset: u64, page_size: u8) -> Result<UserProfilesResponse, Error>;

/// Get `UserCompact` from `user_id`.
async fn get_user_compact_from_id(&self, user_id: i64) -> Result<UserCompact, Error>;
Expand Down
29 changes: 25 additions & 4 deletions src/databases/mysql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use super::database::TABLES_TO_TRUNCATE;
use crate::databases::database;
use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact};
use crate::models::category::CategoryId;
use crate::models::response::TorrentsResponse;
use crate::models::response::{TorrentsResponse, UserProfilesResponse};
use crate::models::torrent::{Metadata, TorrentListing};
use crate::models::torrent_file::{
DbTorrent, DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentHttpSeedUrl, DbTorrentNode, Torrent, TorrentFile,
Expand Down Expand Up @@ -155,11 +155,32 @@ impl Database for Mysql {
.map_err(|_| database::Error::UserNotFound)
}

async fn get_user_profiles(&self) -> Result<Vec<UserProfile>, database::Error> {
query_as::<_, UserProfile>(r"SELECT * FROM torrust_user_profiles")
async fn get_user_profiles_paginated(&self, offset: u64, limit: u8) -> Result<UserProfilesResponse, database::Error> {
let mut query_string = format!("SELECT * FROM torrust_user_profiles");

let count_query = format!("SELECT COUNT(*) as count FROM ({query_string}) AS count_table");

let count_result: Result<i64, database::Error> = query_as(&count_query)
.fetch_one(&self.pool)
.await
.map(|(v,)| v)
.map_err(|_| database::Error::Error);

let count = count_result?;

query_string = format!("{query_string} LIMIT ?, ?");

let res: Vec<UserProfile> = sqlx::query_as::<_, UserProfile>(&query_string)
.bind(i64::saturating_add_unsigned(0, offset))
.bind(limit)
.fetch_all(&self.pool)
.await
.map_err(|_| database::Error::Error)
.map_err(|_| database::Error::Error)?;

Ok(UserProfilesResponse {
total: u32::try_from(count).expect("variable `count` is larger than u32"),
results: res,
})
}

async fn get_user_compact_from_id(&self, user_id: i64) -> Result<UserCompact, database::Error> {
Expand Down
29 changes: 25 additions & 4 deletions src/databases/sqlite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use super::database::TABLES_TO_TRUNCATE;
use crate::databases::database;
use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact};
use crate::models::category::CategoryId;
use crate::models::response::TorrentsResponse;
use crate::models::response::{TorrentsResponse, UserProfilesResponse};
use crate::models::torrent::{Metadata, TorrentListing};
use crate::models::torrent_file::{
DbTorrent, DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentHttpSeedUrl, DbTorrentNode, Torrent, TorrentFile,
Expand Down Expand Up @@ -156,11 +156,32 @@ impl Database for Sqlite {
.map_err(|_| database::Error::UserNotFound)
}

async fn get_user_profiles(&self) -> Result<Vec<UserProfile>, database::Error> {
query_as::<_, UserProfile>("SELECT * FROM torrust_user_profiles")
async fn get_user_profiles_paginated(&self, offset: u64, limit: u8) -> Result<UserProfilesResponse, database::Error> {
let mut query_string = format!("SELECT * FROM torrust_user_profiles");

let count_query = format!("SELECT COUNT(*) as count FROM ({query_string}) AS count_table");

let count_result: Result<i64, database::Error> = query_as(&count_query)
.fetch_one(&self.pool)
.await
.map(|(v,)| v)
.map_err(|_| database::Error::Error);

let count = count_result?;

query_string = format!("{query_string} LIMIT ?, ?");

let res: Vec<UserProfile> = sqlx::query_as::<_, UserProfile>(&query_string)
.bind(i64::saturating_add_unsigned(0, offset))
.bind(limit)
.fetch_all(&self.pool)
.await
.map_err(|_| database::Error::Error)
.map_err(|_| database::Error::Error)?;

Ok(UserProfilesResponse {
total: u32::try_from(count).expect("variable `count` is larger than u32"),
results: res,
})
}

async fn get_user_compact_from_id(&self, user_id: i64) -> Result<UserCompact, database::Error> {
Expand Down
8 changes: 8 additions & 0 deletions src/models/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use url::Url;

use super::category::Category;
use super::torrent::TorrentId;
use super::user::UserProfile;
use crate::databases::database::Category as DatabaseCategory;
use crate::models::torrent::TorrentListing;
use crate::models::torrent_file::TorrentFile;
Expand Down Expand Up @@ -123,3 +124,10 @@ pub struct TorrentsResponse {
pub total: u32,
pub results: Vec<TorrentListing>,
}

#[allow(clippy::module_name_repetitions)]
#[derive(Serialize, Deserialize, Debug, sqlx::FromRow)]
pub struct UserProfilesResponse {
pub total: u32,
pub results: Vec<UserProfile>,
}
4 changes: 2 additions & 2 deletions src/services/authorization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ pub enum ACTION {
GetCanonicalInfoHash,
ChangePassword,
BanUser,
GetUserProfiles,
GenerateUserProfilesListing,
}

pub struct Service {
Expand Down Expand Up @@ -249,7 +249,7 @@ impl Default for CasbinConfiguration {
admin, GetCanonicalInfoHash
admin, ChangePassword
admin, BanUser
admin, GetUserProfiles
admin, GenerateUserProfilesListing
registered, GetAboutPage
registered, GetLicensePage
registered, GetCategories
Expand Down
69 changes: 61 additions & 8 deletions src/services/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
#[cfg(test)]
use mockall::automock;
use pbkdf2::password_hash::rand_core::OsRng;
use serde_derive::Deserialize;
use tracing::{debug, info};

use super::authentication::DbUserAuthenticationRepository;
Expand All @@ -17,6 +18,7 @@ use crate::databases::database::{Database, Error};
use crate::errors::ServiceError;
use crate::mailer;
use crate::mailer::VerifyClaims;
use crate::models::response::UserProfilesResponse;
use crate::models::user::{UserCompact, UserId, UserProfile, Username};
use crate::services::authentication::verify_password;
use crate::utils::validation::validate_email_address;
Expand All @@ -29,6 +31,20 @@ fn no_email() -> String {
String::new()
}

/// User request to generate a user profile listing.
#[derive(Debug, Deserialize)]
pub struct ListingRequest {
pub page_size: Option<u8>,
pub page: Option<u32>,
}

/// Internal specification for user profiles listings.
#[derive(Debug, Deserialize)]
pub struct ListingSpecification {
pub offset: u64,
pub page_size: u8,
}

pub struct RegistrationService {
configuration: Arc<Configuration>,
mailer: Arc<mailer::Service>,
Expand Down Expand Up @@ -322,35 +338,70 @@ impl BanService {
}

pub struct ListingService {
configuration: Arc<Configuration>,
user_profile_repository: Arc<DbUserProfileRepository>,
authorization_service: Arc<authorization::Service>,
}

impl ListingService {
#[must_use]
pub fn new(
configuration: Arc<Configuration>,
user_profile_repository: Arc<DbUserProfileRepository>,
authorization_service: Arc<authorization::Service>,
) -> Self {
Self {
configuration,
user_profile_repository,
authorization_service,
}
}

/// Returns a list of all the user profiles.
/// Returns a list of all the user profiles matching the search criteria.
///
/// # Errors
///
/// This function will return a error if there is a database error retrieving the profiles:
pub async fn get_user_profiles(&self, maybe_user_id: Option<UserId>) -> Result<Vec<UserProfile>, ServiceError> {
/// Returns a `ServiceError::DatabaseError` if the database query fails.
pub async fn generate_user_profile_listing(
&self,
request: &ListingRequest,
maybe_user_id: Option<UserId>,
) -> Result<UserProfilesResponse, ServiceError> {
self.authorization_service
.authorize(ACTION::GetUserProfiles, maybe_user_id)
.authorize(ACTION::GenerateUserProfilesListing, maybe_user_id)
.await?;

let user_profiles = self.user_profile_repository.get_all_user_profiles().await?;
let user_profile_listing_specification = self.listing_specification_from_user_request(request).await;

let user_profiles_response = self
.user_profile_repository
.generate_listing(&user_profile_listing_specification)
.await?;

Ok(user_profiles)
Ok(user_profiles_response)
}

/// It converts the user listing request into an internal listing
/// specification.
async fn listing_specification_from_user_request(&self, request: &ListingRequest) -> ListingSpecification {
let settings = self.configuration.settings.read().await;
let default_user_profile_page_size = settings.api.default_user_profile_page_size;
let max_user_profile_page_size = settings.api.max_user_profile_page_size;
drop(settings);

let page = request.page.unwrap_or(0);
let page_size = request.page_size.unwrap_or(default_user_profile_page_size);

// Guard that page size does not exceed the maximum
let page_size = if page_size > max_user_profile_page_size {
max_user_profile_page_size
} else {
page_size
};

let offset = u64::from(page * u32::from(page_size));

ListingSpecification { offset, page_size }
}
}

Expand Down Expand Up @@ -451,8 +502,10 @@ impl DbUserProfileRepository {
/// # Errors
///
/// It returns an error if there is a database error.
pub async fn get_all_user_profiles(&self) -> Result<Vec<UserProfile>, Error> {
self.database.get_user_profiles().await
pub async fn generate_listing(&self, specification: &ListingSpecification) -> Result<UserProfilesResponse, Error> {
self.database
.get_user_profiles_paginated(specification.offset, specification.page_size)
.await
}
}

Expand Down
10 changes: 8 additions & 2 deletions src/web/api/server/v1/contexts/user/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
//! context.
use std::sync::Arc;

use axum::extract::{self, Host, Path, State};
use axum::extract::{self, Host, Path, Query, State};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Deserialize;

use super::forms::{ChangePasswordForm, JsonWebToken, LoginForm, RegistrationForm};
use super::responses::{self};
use crate::common::AppData;
use crate::services::user::ListingRequest;
use crate::web::api::server::v1::extractors::optional_user_id::ExtractOptionalLoggedInUser;
use crate::web::api::server::v1::responses::OkResponseData;

Expand Down Expand Up @@ -201,9 +202,14 @@ fn api_base_url(host: &str) -> String {
#[allow(clippy::unused_async)]
pub async fn get_user_profiles_handler(
State(app_data): State<Arc<AppData>>,
Query(criteria): Query<ListingRequest>,
ExtractOptionalLoggedInUser(maybe_user_id): ExtractOptionalLoggedInUser,
) -> Response {
match app_data.listing_service.get_user_profiles(maybe_user_id).await {
match app_data
.listing_service
.generate_user_profile_listing(&criteria, maybe_user_id)
.await
{
Ok(users) => Json(crate::web::api::server::v1::responses::OkResponseData { data: users }).into_response(),
Err(error) => error.into_response(),
}
Expand Down

0 comments on commit f1259cc

Please sign in to comment.