feat: implement user follow/unfollow functionality and thought retrieval by user
- Added follow and unfollow endpoints for users. - Implemented logic to retrieve thoughts by a specific user. - Updated user error handling to include cases for already following and not following. - Created persistence layer for follow relationships. - Enhanced user and thought schemas to support new features. - Added tests for follow/unfollow endpoints and thought retrieval. - Updated frontend to display thoughts and allow posting new thoughts.
This commit is contained in:
@@ -27,6 +27,11 @@ impl HTTPError for UserError {
|
||||
fn to_status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
UserError::NotFound => StatusCode::NOT_FOUND,
|
||||
UserError::NotFollowing => StatusCode::BAD_REQUEST,
|
||||
UserError::Forbidden => StatusCode::FORBIDDEN,
|
||||
UserError::UsernameTaken => StatusCode::BAD_REQUEST,
|
||||
UserError::AlreadyFollowing => StatusCode::BAD_REQUEST,
|
||||
UserError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
29
thoughts-backend/api/src/extractor/auth.rs
Normal file
29
thoughts-backend/api/src/extractor/auth.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{request::Parts, StatusCode},
|
||||
};
|
||||
|
||||
use app::state::AppState;
|
||||
|
||||
// A dummy struct to represent an authenticated user.
|
||||
// In a real app, this would contain user details from a validated JWT.
|
||||
pub struct AuthUser {
|
||||
pub id: i32,
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for AuthUser {
|
||||
type Rejection = (StatusCode, &'static str);
|
||||
|
||||
async fn from_request_parts(
|
||||
_parts: &mut Parts,
|
||||
_state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
// For now, we'll just return a hardcoded user.
|
||||
// In a real implementation, you would:
|
||||
// 1. Extract the `Authorization: Bearer <token>` header.
|
||||
// 2. Validate the JWT.
|
||||
// 3. Extract the user ID from the token claims.
|
||||
// 4. Return an error if the token is invalid or missing.
|
||||
Ok(AuthUser { id: 1 }) // Assume user with ID 1 is always authenticated.
|
||||
}
|
||||
}
|
@@ -1,5 +1,7 @@
|
||||
mod auth;
|
||||
mod json;
|
||||
mod valid;
|
||||
|
||||
pub use auth::AuthUser;
|
||||
pub use json::Json;
|
||||
pub use valid::Valid;
|
||||
|
39
thoughts-backend/api/src/routers/feed.rs
Normal file
39
thoughts-backend/api/src/routers/feed.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use axum::{extract::State, response::IntoResponse, routing::get, Json, Router};
|
||||
|
||||
use app::{
|
||||
persistence::{follow::get_followed_ids, thought::get_feed_for_user},
|
||||
state::AppState,
|
||||
};
|
||||
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
|
||||
|
||||
use crate::{error::ApiError, extractor::AuthUser};
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/feed",
|
||||
responses(
|
||||
(status = 200, description = "Authenticated user's feed", body = ThoughtListSchema)
|
||||
),
|
||||
security(
|
||||
("api_key" = []),
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
async fn feed_get(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let followed_ids = get_followed_ids(&state.conn, auth_user.id).await?;
|
||||
let thoughts_with_authors = get_feed_for_user(&state.conn, followed_ids).await?;
|
||||
|
||||
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
||||
.into_iter()
|
||||
.map(ThoughtSchema::from)
|
||||
.collect();
|
||||
|
||||
Ok(Json(ThoughtListSchema::from(thoughts_schema)))
|
||||
}
|
||||
|
||||
pub fn create_feed_router() -> Router<AppState> {
|
||||
Router::new().route("/", get(feed_get))
|
||||
}
|
@@ -1,15 +1,21 @@
|
||||
use axum::Router;
|
||||
|
||||
pub mod feed;
|
||||
pub mod root;
|
||||
pub mod thought;
|
||||
pub mod user;
|
||||
|
||||
use app::state::AppState;
|
||||
use root::create_root_router;
|
||||
use user::create_user_router;
|
||||
|
||||
use crate::routers::{feed::create_feed_router, thought::create_thought_router};
|
||||
|
||||
pub fn create_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.merge(create_root_router())
|
||||
.nest("/users", create_user_router())
|
||||
.nest("/thoughts", create_thought_router())
|
||||
.nest("/feed", create_feed_router())
|
||||
.with_state(state)
|
||||
}
|
||||
|
87
thoughts-backend/api/src/routers/thought.rs
Normal file
87
thoughts-backend/api/src/routers/thought.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{delete, post},
|
||||
Router,
|
||||
};
|
||||
|
||||
use app::{
|
||||
error::UserError,
|
||||
persistence::thought::{create_thought, delete_thought, get_thought},
|
||||
state::AppState,
|
||||
};
|
||||
use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
extractor::{AuthUser, Json, Valid},
|
||||
models::{ApiErrorResponse, ParamsErrorResponse},
|
||||
};
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/thoughts",
|
||||
request_body = CreateThoughtParams,
|
||||
responses(
|
||||
(status = 201, description = "Thought created", body = ThoughtSchema),
|
||||
(status = 400, description = "Bad request", body = ApiErrorResponse),
|
||||
(status = 422, description = "Validation error", body = ParamsErrorResponse)
|
||||
),
|
||||
security(
|
||||
("api_key" = []),
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
async fn thoughts_post(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Valid(Json(params)): Valid<Json<CreateThoughtParams>>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let thought = create_thought(&state.conn, auth_user.id, params).await?;
|
||||
let author = app::persistence::user::get_user(&state.conn, auth_user.id)
|
||||
.await?
|
||||
.ok_or(UserError::NotFound)?; // Should not happen if auth is valid
|
||||
|
||||
let schema = ThoughtSchema::from_models(&thought, &author);
|
||||
Ok((StatusCode::CREATED, Json(schema)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/thoughts/{id}",
|
||||
params(
|
||||
("id" = i32, Path, description = "Thought ID")
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "Thought deleted"),
|
||||
(status = 403, description = "Forbidden", body = ApiErrorResponse),
|
||||
(status = 404, description = "Not Found", body = ApiErrorResponse)
|
||||
),
|
||||
security(
|
||||
("api_key" = []),
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
async fn thoughts_delete(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<i32>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let thought = get_thought(&state.conn, id)
|
||||
.await?
|
||||
.ok_or(UserError::NotFound)?;
|
||||
|
||||
if thought.author_id != auth_user.id {
|
||||
return Err(UserError::Forbidden.into());
|
||||
}
|
||||
|
||||
delete_thought(&state.conn, id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub fn create_thought_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", post(thoughts_post))
|
||||
.route("/{id}", delete(thoughts_delete))
|
||||
}
|
@@ -5,18 +5,22 @@ use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use sea_orm::TryIntoModel;
|
||||
use sea_orm::{DbErr, TryIntoModel};
|
||||
|
||||
use app::error::UserError;
|
||||
use app::persistence::user::{create_user, get_user, search_users};
|
||||
use app::persistence::{
|
||||
follow,
|
||||
thought::get_thoughts_by_user,
|
||||
user::{create_user, get_user, search_users},
|
||||
};
|
||||
use app::state::AppState;
|
||||
use models::params::user::CreateUserParams;
|
||||
use models::queries::user::UserQuery;
|
||||
use app::{error::UserError, persistence::user::get_user_by_username};
|
||||
use models::schemas::user::{UserListSchema, UserSchema};
|
||||
use models::{params::user::CreateUserParams, schemas::thought::ThoughtListSchema};
|
||||
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extractor::{Json, Valid};
|
||||
use crate::models::{ApiErrorResponse, ParamsErrorResponse};
|
||||
use crate::{error::ApiError, extractor::AuthUser};
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
@@ -25,6 +29,7 @@ use crate::models::{ApiErrorResponse, ParamsErrorResponse};
|
||||
responses(
|
||||
(status = 201, description = "User created", body = UserSchema),
|
||||
(status = 400, description = "Bad request", body = ApiErrorResponse),
|
||||
(status = 409, description = "Username already exists", body = ApiErrorResponse),
|
||||
(status = 422, description = "Validation error", body = ParamsErrorResponse),
|
||||
(status = 500, description = "Internal server error", body = ApiErrorResponse),
|
||||
)
|
||||
@@ -86,8 +91,104 @@ async fn users_id_get(
|
||||
.ok_or_else(|| UserError::NotFound.into())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/{username}/thoughts",
|
||||
params(
|
||||
("username" = String, Path, description = "Username")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "List of user's thoughts", body = ThoughtListSchema),
|
||||
(status = 404, description = "User not found", body = ApiErrorResponse)
|
||||
)
|
||||
)]
|
||||
async fn user_thoughts_get(
|
||||
State(state): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let user = get_user_by_username(&state.conn, &username)
|
||||
.await?
|
||||
.ok_or(UserError::NotFound)?;
|
||||
|
||||
let thoughts_with_authors = get_thoughts_by_user(&state.conn, user.id).await?;
|
||||
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
|
||||
.into_iter()
|
||||
.map(ThoughtSchema::from)
|
||||
.collect();
|
||||
|
||||
Ok(Json(ThoughtListSchema::from(thoughts_schema)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/{username}/follow",
|
||||
params(
|
||||
("username" = String, Path, description = "Username to follow")
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "User followed successfully"),
|
||||
(status = 404, description = "User not found", body = ApiErrorResponse),
|
||||
(status = 409, description = "Already following", body = ApiErrorResponse)
|
||||
),
|
||||
security(
|
||||
("api_key" = []),
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
async fn user_follow_post(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(username): Path<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let user_to_follow = get_user_by_username(&state.conn, &username)
|
||||
.await?
|
||||
.ok_or(UserError::NotFound)?;
|
||||
|
||||
let result = follow::follow_user(&state.conn, auth_user.id, user_to_follow.id).await;
|
||||
|
||||
match result {
|
||||
Ok(_) => Ok(StatusCode::NO_CONTENT),
|
||||
Err(DbErr::UnpackInsertId) => Err(UserError::AlreadyFollowing.into()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/{username}/follow",
|
||||
params(
|
||||
("username" = String, Path, description = "Username to unfollow")
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "User unfollowed successfully"),
|
||||
(status = 404, description = "User not found or not being followed", body = ApiErrorResponse)
|
||||
),
|
||||
security(
|
||||
("api_key" = []),
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
async fn user_follow_delete(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
Path(username): Path<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let user_to_unfollow = get_user_by_username(&state.conn, &username)
|
||||
.await?
|
||||
.ok_or(UserError::NotFound)?;
|
||||
|
||||
follow::unfollow_user(&state.conn, auth_user.id, user_to_unfollow.id).await?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub fn create_user_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", post(users_post).get(users_get))
|
||||
.route("/{id}", get(users_id_get))
|
||||
.route("/{username}/thoughts", get(user_thoughts_get))
|
||||
.route(
|
||||
"/{username}/follow",
|
||||
post(user_follow_post).delete(user_follow_delete),
|
||||
)
|
||||
}
|
||||
|
Reference in New Issue
Block a user