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 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,
|
||||
};
|
||||
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
|
||||
@@ -24,12 +24,8 @@ async fn feed_get(
|
||||
auth_user: AuthUser,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let following_ids = get_following_ids(&state.conn, auth_user.id).await?;
|
||||
let mut thoughts_with_authors =
|
||||
get_feed_for_user(&state.conn, following_ids, Some(auth_user.id)).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_with_authors =
|
||||
get_feed_for_users_and_self(&state.conn, auth_user.id, following_ids).await?;
|
||||
|
||||
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
||||
.into_iter()
|
||||
|
@@ -17,8 +17,14 @@ use app::persistence::{
|
||||
};
|
||||
use app::state::AppState;
|
||||
use app::{error::UserError, persistence::user::get_user_by_username};
|
||||
use models::schemas::user::{MeSchema, UserListSchema, UserSchema};
|
||||
use models::{params::user::UpdateUserParams, schemas::thought::ThoughtListSchema};
|
||||
use models::{
|
||||
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 crate::{error::ApiError, extractor::AuthUser};
|
||||
@@ -418,16 +424,31 @@ async fn get_user_followers(
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/all",
|
||||
params(PaginationQuery),
|
||||
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"
|
||||
)]
|
||||
async fn get_all_users_public(
|
||||
State(state): State<AppState>,
|
||||
Query(pagination): Query<PaginationQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let users = get_all_users(&state.conn).await?;
|
||||
Ok(Json(UserListSchema::from(users)))
|
||||
let (users, total_items) = get_all_users(&state.conn, &pagination).await?;
|
||||
|
||||
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> {
|
||||
|
@@ -156,6 +156,37 @@ pub async fn get_feed_for_user(
|
||||
.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(
|
||||
db: &DbConn,
|
||||
tag_name: &str,
|
||||
|
@@ -1,7 +1,8 @@
|
||||
use models::queries::pagination::PaginationQuery;
|
||||
use sea_orm::prelude::Uuid;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, QueryFilter, QueryOrder,
|
||||
QuerySelect, RelationTrait, Set, TransactionTrait,
|
||||
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, PaginatorTrait,
|
||||
QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, TransactionTrait,
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
pub async fn get_all_users(db: &DbConn) -> Result<Vec<user::Model>, DbErr> {
|
||||
user::Entity::find()
|
||||
pub async fn get_all_users(
|
||||
db: &DbConn,
|
||||
pagination: &PaginationQuery,
|
||||
) -> Result<(Vec<user::Model>, u64), DbErr> {
|
||||
let paginator = user::Entity::find()
|
||||
.order_by_desc(user::Column::CreatedAt)
|
||||
.all(db)
|
||||
.await
|
||||
.paginate(db, pagination.page_size());
|
||||
|
||||
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;
|
||||
|
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 pagination;
|
||||
pub mod search;
|
||||
pub mod thought;
|
||||
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 axum::http::StatusCode;
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::json;
|
||||
use tokio::time::sleep;
|
||||
use utils::testing::make_jwt_request;
|
||||
|
||||
#[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]["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]
|
||||
async fn test_get_all_users_public() {
|
||||
async fn test_get_all_users_paginated() {
|
||||
let app = setup().await;
|
||||
|
||||
create_user_with_password(&app.db, "userA", "password123", "a@example.com").await;
|
||||
create_user_with_password(&app.db, "userB", "password123", "b@example.com").await;
|
||||
create_user_with_password(&app.db, "userC", "password123", "c@example.com").await;
|
||||
for i in 0..25 {
|
||||
create_user_with_password(
|
||||
&app.db,
|
||||
&format!("user{}", i),
|
||||
"password123",
|
||||
&format!("u{}@e.com", i),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let response = make_get_request(app.router.clone(), "/users/all", None).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||
let users_list = v["users"].as_array().unwrap();
|
||||
let response_p1 = make_get_request(app.router.clone(), "/users/all", None).await;
|
||||
assert_eq!(response_p1.status(), StatusCode::OK);
|
||||
let body_p1 = response_p1.into_body().collect().await.unwrap().to_bytes();
|
||||
let v_p1: Value = serde_json::from_slice(&body_p1).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
users_list.len(),
|
||||
3,
|
||||
"Should return a list of all 3 registered users"
|
||||
v_p1["items"].as_array().unwrap().len(),
|
||||
20,
|
||||
"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