feat: implement pagination for user retrieval and update feed fetching logic
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 2m30s
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 2m30s
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
use axum::{extract::State, response::IntoResponse, routing::get, Json, Router};
|
use axum::{extract::State, response::IntoResponse, routing::get, Json, Router};
|
||||||
|
|
||||||
use app::{
|
use app::{
|
||||||
persistence::{follow::get_following_ids, thought::get_feed_for_user},
|
persistence::{follow::get_following_ids, thought::get_feed_for_users_and_self},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
|
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
|
||||||
@@ -24,12 +24,8 @@ async fn feed_get(
|
|||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let following_ids = get_following_ids(&state.conn, auth_user.id).await?;
|
let following_ids = get_following_ids(&state.conn, auth_user.id).await?;
|
||||||
let mut thoughts_with_authors =
|
let thoughts_with_authors =
|
||||||
get_feed_for_user(&state.conn, following_ids, Some(auth_user.id)).await?;
|
get_feed_for_users_and_self(&state.conn, auth_user.id, following_ids).await?;
|
||||||
|
|
||||||
let own_thoughts =
|
|
||||||
get_feed_for_user(&state.conn, vec![auth_user.id], Some(auth_user.id)).await?;
|
|
||||||
thoughts_with_authors.extend(own_thoughts);
|
|
||||||
|
|
||||||
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
@@ -17,8 +17,14 @@ use app::persistence::{
|
|||||||
};
|
};
|
||||||
use app::state::AppState;
|
use app::state::AppState;
|
||||||
use app::{error::UserError, persistence::user::get_user_by_username};
|
use app::{error::UserError, persistence::user::get_user_by_username};
|
||||||
use models::schemas::user::{MeSchema, UserListSchema, UserSchema};
|
use models::{
|
||||||
use models::{params::user::UpdateUserParams, schemas::thought::ThoughtListSchema};
|
params::user::UpdateUserParams,
|
||||||
|
schemas::{pagination::PaginatedResponse, thought::ThoughtListSchema},
|
||||||
|
};
|
||||||
|
use models::{
|
||||||
|
queries::pagination::PaginationQuery,
|
||||||
|
schemas::user::{MeSchema, UserListSchema, UserSchema},
|
||||||
|
};
|
||||||
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
|
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
|
||||||
|
|
||||||
use crate::{error::ApiError, extractor::AuthUser};
|
use crate::{error::ApiError, extractor::AuthUser};
|
||||||
@@ -418,16 +424,31 @@ async fn get_user_followers(
|
|||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/all",
|
path = "/all",
|
||||||
|
params(PaginationQuery),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "A public list of all users", body = UserListSchema)
|
(status = 200, description = "A public, paginated list of all users", body = PaginatedResponse<UserSchema>)
|
||||||
),
|
),
|
||||||
tag = "user"
|
tag = "user"
|
||||||
)]
|
)]
|
||||||
async fn get_all_users_public(
|
async fn get_all_users_public(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
Query(pagination): Query<PaginationQuery>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let users = get_all_users(&state.conn).await?;
|
let (users, total_items) = get_all_users(&state.conn, &pagination).await?;
|
||||||
Ok(Json(UserListSchema::from(users)))
|
|
||||||
|
let page = pagination.page();
|
||||||
|
let page_size = pagination.page_size();
|
||||||
|
let total_pages = (total_items as f64 / page_size as f64).ceil() as u64;
|
||||||
|
|
||||||
|
let response = PaginatedResponse {
|
||||||
|
items: users.into_iter().map(UserSchema::from).collect(),
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
total_pages,
|
||||||
|
total_items,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_user_router() -> Router<AppState> {
|
pub fn create_user_router() -> Router<AppState> {
|
||||||
|
@@ -156,6 +156,37 @@ pub async fn get_feed_for_user(
|
|||||||
.map_err(|e| UserError::Internal(e.to_string()))
|
.map_err(|e| UserError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_feed_for_users_and_self(
|
||||||
|
db: &DbConn,
|
||||||
|
user_id: Uuid,
|
||||||
|
following_ids: Vec<Uuid>,
|
||||||
|
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
|
||||||
|
let mut authors_to_include = following_ids;
|
||||||
|
authors_to_include.push(user_id);
|
||||||
|
|
||||||
|
thought::Entity::find()
|
||||||
|
.select_only()
|
||||||
|
.column(thought::Column::Id)
|
||||||
|
.column(thought::Column::Content)
|
||||||
|
.column(thought::Column::ReplyToId)
|
||||||
|
.column(thought::Column::CreatedAt)
|
||||||
|
.column(thought::Column::Visibility)
|
||||||
|
.column(thought::Column::AuthorId)
|
||||||
|
.column_as(user::Column::Username, "author_username")
|
||||||
|
.column_as(user::Column::DisplayName, "author_display_name")
|
||||||
|
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
||||||
|
.filter(thought::Column::AuthorId.is_in(authors_to_include))
|
||||||
|
.filter(
|
||||||
|
Condition::any()
|
||||||
|
.add(thought::Column::Visibility.eq(thought::Visibility::Public))
|
||||||
|
.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly)),
|
||||||
|
)
|
||||||
|
.order_by_desc(thought::Column::CreatedAt)
|
||||||
|
.into_model::<ThoughtWithAuthor>()
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_thoughts_by_tag_name(
|
pub async fn get_thoughts_by_tag_name(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
tag_name: &str,
|
tag_name: &str,
|
||||||
|
@@ -1,7 +1,8 @@
|
|||||||
|
use models::queries::pagination::PaginationQuery;
|
||||||
use sea_orm::prelude::Uuid;
|
use sea_orm::prelude::Uuid;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, QueryFilter, QueryOrder,
|
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, PaginatorTrait,
|
||||||
QuerySelect, RelationTrait, Set, TransactionTrait,
|
QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, TransactionTrait,
|
||||||
};
|
};
|
||||||
|
|
||||||
use models::domains::{top_friends, user};
|
use models::domains::{top_friends, user};
|
||||||
@@ -166,9 +167,16 @@ pub async fn get_followers(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model
|
|||||||
get_users_by_ids(db, follower_ids).await
|
get_users_by_ids(db, follower_ids).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_all_users(db: &DbConn) -> Result<Vec<user::Model>, DbErr> {
|
pub async fn get_all_users(
|
||||||
user::Entity::find()
|
db: &DbConn,
|
||||||
|
pagination: &PaginationQuery,
|
||||||
|
) -> Result<(Vec<user::Model>, u64), DbErr> {
|
||||||
|
let paginator = user::Entity::find()
|
||||||
.order_by_desc(user::Column::CreatedAt)
|
.order_by_desc(user::Column::CreatedAt)
|
||||||
.all(db)
|
.paginate(db, pagination.page_size());
|
||||||
.await
|
|
||||||
|
let total_items = paginator.num_items().await?;
|
||||||
|
let users = paginator.fetch_page(pagination.page() - 1).await?;
|
||||||
|
|
||||||
|
Ok((users, total_items))
|
||||||
}
|
}
|
||||||
|
@@ -1 +1,2 @@
|
|||||||
|
pub mod pagination;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
27
thoughts-backend/models/src/queries/pagination.rs
Normal file
27
thoughts-backend/models/src/queries/pagination.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use utoipa::IntoParams;
|
||||||
|
|
||||||
|
const DEFAULT_PAGE: u64 = 1;
|
||||||
|
const DEFAULT_PAGE_SIZE: u64 = 20;
|
||||||
|
|
||||||
|
#[derive(Deserialize, IntoParams)]
|
||||||
|
pub struct PaginationQuery {
|
||||||
|
#[param(nullable = true, example = 1)]
|
||||||
|
page: Option<u64>,
|
||||||
|
#[param(nullable = true, example = 20)]
|
||||||
|
page_size: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PaginationQuery {
|
||||||
|
pub fn page(&self) -> u64 {
|
||||||
|
self.page.unwrap_or(DEFAULT_PAGE).max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn page_size(&self) -> u64 {
|
||||||
|
self.page_size.unwrap_or(DEFAULT_PAGE_SIZE).max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn offset(&self) -> u64 {
|
||||||
|
(self.page() - 1) * self.page_size()
|
||||||
|
}
|
||||||
|
}
|
@@ -1,4 +1,5 @@
|
|||||||
pub mod api_key;
|
pub mod api_key;
|
||||||
|
pub mod pagination;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
12
thoughts-backend/models/src/schemas/pagination.rs
Normal file
12
thoughts-backend/models/src/schemas/pagination.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PaginatedResponse<T> {
|
||||||
|
pub items: Vec<T>,
|
||||||
|
pub page: u64,
|
||||||
|
pub page_size: u64,
|
||||||
|
pub total_pages: u64,
|
||||||
|
pub total_items: u64,
|
||||||
|
}
|
@@ -1,7 +1,10 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use super::main::{create_user_with_password, setup};
|
use super::main::{create_user_with_password, setup};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use tokio::time::sleep;
|
||||||
use utils::testing::make_jwt_request;
|
use utils::testing::make_jwt_request;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -84,3 +87,80 @@ async fn test_feed_and_user_thoughts() {
|
|||||||
assert_eq!(v["thoughts"][1]["authorUsername"], "user1");
|
assert_eq!(v["thoughts"][1]["authorUsername"], "user1");
|
||||||
assert_eq!(v["thoughts"][1]["content"], "A thought from user1");
|
assert_eq!(v["thoughts"][1]["content"], "A thought from user1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_feed_strict_chronological_order() {
|
||||||
|
let app = setup().await;
|
||||||
|
create_user_with_password(&app.db, "user1", "password123", "u1@e.com").await;
|
||||||
|
create_user_with_password(&app.db, "user2", "password123", "u2@e.com").await;
|
||||||
|
create_user_with_password(&app.db, "user3", "password123", "u3@e.com").await;
|
||||||
|
|
||||||
|
let token1 = super::main::login_user(app.router.clone(), "user1", "password123").await;
|
||||||
|
let token2 = super::main::login_user(app.router.clone(), "user2", "password123").await;
|
||||||
|
let token3 = super::main::login_user(app.router.clone(), "user3", "password123").await;
|
||||||
|
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/user2/follow",
|
||||||
|
"POST",
|
||||||
|
None,
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/user3/follow",
|
||||||
|
"POST",
|
||||||
|
None,
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let body_t1 = json!({ "content": "Thought 1 from user2" }).to_string();
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(body_t1),
|
||||||
|
&token2,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
sleep(Duration::from_millis(10)).await;
|
||||||
|
|
||||||
|
let body_t2 = json!({ "content": "Thought 2 from user3" }).to_string();
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(body_t2),
|
||||||
|
&token3,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
sleep(Duration::from_millis(10)).await;
|
||||||
|
|
||||||
|
let body_t3 = json!({ "content": "Thought 3 from user2" }).to_string();
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(body_t3),
|
||||||
|
&token2,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let response = make_jwt_request(app.router.clone(), "/feed", "GET", None, &token1).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
let thoughts = v["thoughts"].as_array().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
thoughts.len(),
|
||||||
|
3,
|
||||||
|
"Feed should contain 3 thoughts from followed users"
|
||||||
|
);
|
||||||
|
assert_eq!(thoughts[0]["content"], "Thought 3 from user2");
|
||||||
|
assert_eq!(thoughts[1]["content"], "Thought 2 from user3");
|
||||||
|
assert_eq!(thoughts[2]["content"], "Thought 1 from user2");
|
||||||
|
}
|
||||||
|
@@ -247,23 +247,44 @@ async fn test_update_me_css_and_images() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_get_all_users_public() {
|
async fn test_get_all_users_paginated() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
|
|
||||||
create_user_with_password(&app.db, "userA", "password123", "a@example.com").await;
|
for i in 0..25 {
|
||||||
create_user_with_password(&app.db, "userB", "password123", "b@example.com").await;
|
create_user_with_password(
|
||||||
create_user_with_password(&app.db, "userC", "password123", "c@example.com").await;
|
&app.db,
|
||||||
|
&format!("user{}", i),
|
||||||
|
"password123",
|
||||||
|
&format!("u{}@e.com", i),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
let response = make_get_request(app.router.clone(), "/users/all", None).await;
|
let response_p1 = make_get_request(app.router.clone(), "/users/all", None).await;
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response_p1.status(), StatusCode::OK);
|
||||||
|
let body_p1 = response_p1.into_body().collect().await.unwrap().to_bytes();
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let v_p1: Value = serde_json::from_slice(&body_p1).unwrap();
|
||||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
|
||||||
let users_list = v["users"].as_array().unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
users_list.len(),
|
v_p1["items"].as_array().unwrap().len(),
|
||||||
3,
|
20,
|
||||||
"Should return a list of all 3 registered users"
|
"First page should have 20 items"
|
||||||
);
|
);
|
||||||
|
assert_eq!(v_p1["page"], 1);
|
||||||
|
assert_eq!(v_p1["pageSize"], 20);
|
||||||
|
assert_eq!(v_p1["totalPages"], 2);
|
||||||
|
assert_eq!(v_p1["totalItems"], 25);
|
||||||
|
|
||||||
|
let response_p2 = make_get_request(app.router.clone(), "/users/all?page=2", None).await;
|
||||||
|
assert_eq!(response_p2.status(), StatusCode::OK);
|
||||||
|
let body_p2 = response_p2.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v_p2: Value = serde_json::from_slice(&body_p2).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
v_p2["items"].as_array().unwrap().len(),
|
||||||
|
5,
|
||||||
|
"Second page should have 5 items"
|
||||||
|
);
|
||||||
|
assert_eq!(v_p2["page"], 2);
|
||||||
|
assert_eq!(v_p2["totalPages"], 2);
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user