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:
2025-09-05 19:08:37 +02:00
parent 912259ef54
commit decf81e535
31 changed files with 872 additions and 155 deletions

View File

@@ -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,
}
}
}

View 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.
}
}

View File

@@ -1,5 +1,7 @@
mod auth;
mod json;
mod valid;
pub use auth::AuthUser;
pub use json::Json;
pub use valid::Valid;

View 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))
}

View File

@@ -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)
}

View 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))
}

View File

@@ -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),
)
}