Skip to content

Commit 33392f4

Browse files
committed
Merge torrust#788: New API endpoint for listing users
1c62a9e refactor: [torrust#796] minor correction in handler comment (Mario) 3efaf48 refactor: [torrust#796] fix minor clippy error (Mario) f1259cc feat: [torrust#796] added pagination (Mario) ae1e6a9 refactor: [torrust#796] minor variable renaming (Mario) ef0c1e7 refactor: [torrust#658] More renaming (Mario) b4108dc feat: [torrust#658] new API endpoint for listing user profiles (Mario) 185e17c refactor: [torrust#658] modified naming to make the code more self explanatory (Mario) 23dfbdd ci: [torrust#658] FIxed Mysql E2E tests failing (Mario) 83aaea2 docs: [torrust#658] fixed docs CI error (Mario) b44ea9b feat: [torrust#658] New API endpoint for getting all the users (Mario) e8bd16d ci: [torrust#658] fixed minor linting error (Mario) deb738e feat: [torrust#658] new users listing service (Mario) f48c4a2 feat: [torrust#658] new repository method to get all user profiles (Mario) 9d5c639 feat: [torrust#658] new database method to get all user profiles (Mario) Pull request description: Parent issue [torrust#658](torrust/torrust-index-gui#658) Resolves torrust#796 ACKs for top commit: josecelano: ACK 1c62a9e Tree-SHA512: 767f691e0fdb4ec4007b863da6f21186ac9c4d703cb1a093203bbaf3036d18498e3aeda8ecbc2c608f466de3509fb3219757a209c62e04c5e40e9671a468d8a6
2 parents 3bd92fa + 1c62a9e commit 33392f4

File tree

12 files changed

+232
-6
lines changed

12 files changed

+232
-6
lines changed

src/app.rs

+8
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,13 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running
160160

161161
let about_service = Arc::new(about::Service::new(authorization_service.clone()));
162162

163+
let listing_service = Arc::new(user::ListingService::new(
164+
configuration.clone(),
165+
user_profile_repository.clone(),
166+
authorization_service.clone(),
167+
))
168+
.clone();
169+
163170
// Build app container
164171

165172
let app_data = Arc::new(AppData::new(
@@ -194,6 +201,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running
194201
profile_service,
195202
ban_service,
196203
about_service,
204+
listing_service,
197205
));
198206

199207
// Start cronjob to import tracker torrent data and updating

src/common.rs

+3
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ pub struct AppData {
5252
pub profile_service: Arc<user::ProfileService>,
5353
pub ban_service: Arc<user::BanService>,
5454
pub about_service: Arc<about::Service>,
55+
pub listing_service: Arc<user::ListingService>,
5556
}
5657

5758
impl AppData {
@@ -90,6 +91,7 @@ impl AppData {
9091
profile_service: Arc<user::ProfileService>,
9192
ban_service: Arc<user::BanService>,
9293
about_service: Arc<about::Service>,
94+
listing_service: Arc<user::ListingService>,
9395
) -> AppData {
9496
AppData {
9597
cfg,
@@ -125,6 +127,7 @@ impl AppData {
125127
profile_service,
126128
ban_service,
127129
about_service,
130+
listing_service,
128131
}
129132
}
130133
}

src/config/v2/api.rs

+18
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,23 @@ pub struct Api {
1010
/// The maximum page size for torrent lists.
1111
#[serde(default = "Api::default_max_torrent_page_size")]
1212
pub max_torrent_page_size: u8,
13+
14+
/// The default page size for user profile lists.
15+
#[serde(default = "Api::default_user_profile_page_size")]
16+
pub default_user_profile_page_size: u8,
17+
18+
/// The maximum page size for user profile lists.
19+
#[serde(default = "Api::default_max_user_profile_page_size")]
20+
pub max_user_profile_page_size: u8,
1321
}
1422

1523
impl Default for Api {
1624
fn default() -> Self {
1725
Self {
1826
default_torrent_page_size: Api::default_default_torrent_page_size(),
1927
max_torrent_page_size: Api::default_max_torrent_page_size(),
28+
default_user_profile_page_size: Api::default_user_profile_page_size(),
29+
max_user_profile_page_size: Api::default_max_user_profile_page_size(),
2030
}
2131
}
2232
}
@@ -29,4 +39,12 @@ impl Api {
2939
fn default_max_torrent_page_size() -> u8 {
3040
30
3141
}
42+
43+
fn default_user_profile_page_size() -> u8 {
44+
10
45+
}
46+
47+
fn default_max_user_profile_page_size() -> u8 {
48+
100
49+
}
3250
}

src/databases/database.rs

+4-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use url::Url;
77
use crate::databases::mysql::Mysql;
88
use crate::databases::sqlite::Sqlite;
99
use crate::models::category::CategoryId;
10-
use crate::models::response::TorrentsResponse;
10+
use crate::models::response::{TorrentsResponse, UserProfilesResponse};
1111
use crate::models::torrent::{Metadata, TorrentListing};
1212
use crate::models::torrent_file::{DbTorrent, Torrent, TorrentFile};
1313
use crate::models::torrent_tag::{TagId, TorrentTag};
@@ -143,6 +143,9 @@ pub trait Database: Sync + Send {
143143
/// Get `UserProfile` from `username`.
144144
async fn get_user_profile_from_username(&self, username: &str) -> Result<UserProfile, Error>;
145145

146+
/// Get all user profiles in a paginated form as `UserProfilesResponse`.
147+
async fn get_user_profiles_paginated(&self, offset: u64, page_size: u8) -> Result<UserProfilesResponse, Error>;
148+
146149
/// Get `UserCompact` from `user_id`.
147150
async fn get_user_compact_from_id(&self, user_id: i64) -> Result<UserCompact, Error>;
148151

src/databases/mysql.rs

+29-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use super::database::TABLES_TO_TRUNCATE;
1212
use crate::databases::database;
1313
use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact};
1414
use crate::models::category::CategoryId;
15-
use crate::models::response::TorrentsResponse;
15+
use crate::models::response::{TorrentsResponse, UserProfilesResponse};
1616
use crate::models::torrent::{Metadata, TorrentListing};
1717
use crate::models::torrent_file::{
1818
DbTorrent, DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentHttpSeedUrl, DbTorrentNode, Torrent, TorrentFile,
@@ -155,6 +155,34 @@ impl Database for Mysql {
155155
.map_err(|_| database::Error::UserNotFound)
156156
}
157157

158+
async fn get_user_profiles_paginated(&self, offset: u64, limit: u8) -> Result<UserProfilesResponse, database::Error> {
159+
let mut query_string = "SELECT * FROM torrust_user_profiles".to_string();
160+
161+
let count_query = format!("SELECT COUNT(*) as count FROM ({query_string}) AS count_table");
162+
163+
let count_result: Result<i64, database::Error> = query_as(&count_query)
164+
.fetch_one(&self.pool)
165+
.await
166+
.map(|(v,)| v)
167+
.map_err(|_| database::Error::Error);
168+
169+
let count = count_result?;
170+
171+
query_string = format!("{query_string} LIMIT ?, ?");
172+
173+
let res: Vec<UserProfile> = sqlx::query_as::<_, UserProfile>(&query_string)
174+
.bind(i64::saturating_add_unsigned(0, offset))
175+
.bind(limit)
176+
.fetch_all(&self.pool)
177+
.await
178+
.map_err(|_| database::Error::Error)?;
179+
180+
Ok(UserProfilesResponse {
181+
total: u32::try_from(count).expect("variable `count` is larger than u32"),
182+
results: res,
183+
})
184+
}
185+
158186
async fn get_user_compact_from_id(&self, user_id: i64) -> Result<UserCompact, database::Error> {
159187
query_as::<_, UserCompact>("SELECT tu.user_id, tp.username, tu.administrator FROM torrust_users tu INNER JOIN torrust_user_profiles tp ON tu.user_id = tp.user_id WHERE tu.user_id = ?")
160188
.bind(user_id)

src/databases/sqlite.rs

+29-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use super::database::TABLES_TO_TRUNCATE;
1212
use crate::databases::database;
1313
use crate::databases::database::{Category, Database, Driver, Sorting, TorrentCompact};
1414
use crate::models::category::CategoryId;
15-
use crate::models::response::TorrentsResponse;
15+
use crate::models::response::{TorrentsResponse, UserProfilesResponse};
1616
use crate::models::torrent::{Metadata, TorrentListing};
1717
use crate::models::torrent_file::{
1818
DbTorrent, DbTorrentAnnounceUrl, DbTorrentFile, DbTorrentHttpSeedUrl, DbTorrentNode, Torrent, TorrentFile,
@@ -156,6 +156,34 @@ impl Database for Sqlite {
156156
.map_err(|_| database::Error::UserNotFound)
157157
}
158158

159+
async fn get_user_profiles_paginated(&self, offset: u64, limit: u8) -> Result<UserProfilesResponse, database::Error> {
160+
let mut query_string = "SELECT * FROM torrust_user_profiles".to_string();
161+
162+
let count_query = format!("SELECT COUNT(*) as count FROM ({query_string}) AS count_table");
163+
164+
let count_result: Result<i64, database::Error> = query_as(&count_query)
165+
.fetch_one(&self.pool)
166+
.await
167+
.map(|(v,)| v)
168+
.map_err(|_| database::Error::Error);
169+
170+
let count = count_result?;
171+
172+
query_string = format!("{query_string} LIMIT ?, ?");
173+
174+
let res: Vec<UserProfile> = sqlx::query_as::<_, UserProfile>(&query_string)
175+
.bind(i64::saturating_add_unsigned(0, offset))
176+
.bind(limit)
177+
.fetch_all(&self.pool)
178+
.await
179+
.map_err(|_| database::Error::Error)?;
180+
181+
Ok(UserProfilesResponse {
182+
total: u32::try_from(count).expect("variable `count` is larger than u32"),
183+
results: res,
184+
})
185+
}
186+
159187
async fn get_user_compact_from_id(&self, user_id: i64) -> Result<UserCompact, database::Error> {
160188
query_as::<_, UserCompact>("SELECT tu.user_id, tp.username, tu.administrator FROM torrust_users tu INNER JOIN torrust_user_profiles tp ON tu.user_id = tp.user_id WHERE tu.user_id = ?")
161189
.bind(user_id)

src/models/response.rs

+8
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use url::Url;
33

44
use super::category::Category;
55
use super::torrent::TorrentId;
6+
use super::user::UserProfile;
67
use crate::databases::database::Category as DatabaseCategory;
78
use crate::models::torrent::TorrentListing;
89
use crate::models::torrent_file::TorrentFile;
@@ -123,3 +124,10 @@ pub struct TorrentsResponse {
123124
pub total: u32,
124125
pub results: Vec<TorrentListing>,
125126
}
127+
128+
#[allow(clippy::module_name_repetitions)]
129+
#[derive(Serialize, Deserialize, Debug, sqlx::FromRow)]
130+
pub struct UserProfilesResponse {
131+
pub total: u32,
132+
pub results: Vec<UserProfile>,
133+
}

src/services/authorization.rs

+2
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ pub enum ACTION {
5252
GetCanonicalInfoHash,
5353
ChangePassword,
5454
BanUser,
55+
GenerateUserProfilesListing,
5556
}
5657

5758
pub struct Service {
@@ -248,6 +249,7 @@ impl Default for CasbinConfiguration {
248249
admin, GetCanonicalInfoHash
249250
admin, ChangePassword
250251
admin, BanUser
252+
admin, GenerateUserProfilesListing
251253
registered, GetAboutPage
252254
registered, GetLicensePage
253255
registered, GetCategories

src/services/user.rs

+95
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
88
#[cfg(test)]
99
use mockall::automock;
1010
use pbkdf2::password_hash::rand_core::OsRng;
11+
use serde_derive::Deserialize;
1112
use tracing::{debug, info};
1213

1314
use super::authentication::DbUserAuthenticationRepository;
@@ -17,6 +18,7 @@ use crate::databases::database::{Database, Error};
1718
use crate::errors::ServiceError;
1819
use crate::mailer;
1920
use crate::mailer::VerifyClaims;
21+
use crate::models::response::UserProfilesResponse;
2022
use crate::models::user::{UserCompact, UserId, UserProfile, Username};
2123
use crate::services::authentication::verify_password;
2224
use crate::utils::validation::validate_email_address;
@@ -29,6 +31,20 @@ fn no_email() -> String {
2931
String::new()
3032
}
3133

34+
/// User request to generate a user profile listing.
35+
#[derive(Debug, Deserialize)]
36+
pub struct ListingRequest {
37+
pub page_size: Option<u8>,
38+
pub page: Option<u32>,
39+
}
40+
41+
/// Internal specification for user profiles listings.
42+
#[derive(Debug, Deserialize)]
43+
pub struct ListingSpecification {
44+
pub offset: u64,
45+
pub page_size: u8,
46+
}
47+
3248
pub struct RegistrationService {
3349
configuration: Arc<Configuration>,
3450
mailer: Arc<mailer::Service>,
@@ -321,6 +337,74 @@ impl BanService {
321337
}
322338
}
323339

340+
pub struct ListingService {
341+
configuration: Arc<Configuration>,
342+
user_profile_repository: Arc<DbUserProfileRepository>,
343+
authorization_service: Arc<authorization::Service>,
344+
}
345+
346+
impl ListingService {
347+
#[must_use]
348+
pub fn new(
349+
configuration: Arc<Configuration>,
350+
user_profile_repository: Arc<DbUserProfileRepository>,
351+
authorization_service: Arc<authorization::Service>,
352+
) -> Self {
353+
Self {
354+
configuration,
355+
user_profile_repository,
356+
authorization_service,
357+
}
358+
}
359+
360+
/// Returns a list of all the user profiles matching the search criteria.
361+
///
362+
/// # Errors
363+
///
364+
/// Returns a `ServiceError::DatabaseError` if the database query fails.
365+
pub async fn generate_user_profile_listing(
366+
&self,
367+
request: &ListingRequest,
368+
maybe_user_id: Option<UserId>,
369+
) -> Result<UserProfilesResponse, ServiceError> {
370+
self.authorization_service
371+
.authorize(ACTION::GenerateUserProfilesListing, maybe_user_id)
372+
.await?;
373+
374+
let user_profile_listing_specification = self.listing_specification_from_user_request(request).await;
375+
376+
let user_profiles_response = self
377+
.user_profile_repository
378+
.generate_listing(&user_profile_listing_specification)
379+
.await?;
380+
381+
Ok(user_profiles_response)
382+
}
383+
384+
/// It converts the user listing request into an internal listing
385+
/// specification.
386+
async fn listing_specification_from_user_request(&self, request: &ListingRequest) -> ListingSpecification {
387+
let settings = self.configuration.settings.read().await;
388+
let default_user_profile_page_size = settings.api.default_user_profile_page_size;
389+
let max_user_profile_page_size = settings.api.max_user_profile_page_size;
390+
drop(settings);
391+
392+
let page = request.page.unwrap_or(0);
393+
let page_size = request.page_size.unwrap_or(default_user_profile_page_size);
394+
395+
// Guard that page size does not exceed the maximum
396+
let page_size = if page_size > max_user_profile_page_size {
397+
max_user_profile_page_size
398+
} else {
399+
page_size
400+
};
401+
402+
let offset = u64::from(page * u32::from(page_size));
403+
404+
ListingSpecification { offset, page_size }
405+
}
406+
}
407+
324408
#[cfg_attr(test, automock)]
325409
#[async_trait]
326410
pub trait Repository: Sync + Send {
@@ -412,6 +496,17 @@ impl DbUserProfileRepository {
412496
pub async fn get_user_profile_from_username(&self, username: &str) -> Result<UserProfile, Error> {
413497
self.database.get_user_profile_from_username(username).await
414498
}
499+
500+
/// It gets all the user profiles for all the users.
501+
///
502+
/// # Errors
503+
///
504+
/// It returns an error if there is a database error.
505+
pub async fn generate_listing(&self, specification: &ListingSpecification) -> Result<UserProfilesResponse, Error> {
506+
self.database
507+
.get_user_profiles_paginated(specification.offset, specification.page_size)
508+
.await
509+
}
415510
}
416511

417512
pub struct DbBannedUserList {

0 commit comments

Comments
 (0)