From decf81e5355a9fe6d2d53a428ae9d6a15ad9ba88 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 5 Sep 2025 19:08:37 +0200 Subject: [PATCH] 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. --- thoughts-backend/Cargo.lock | 1 + thoughts-backend/Cargo.toml | 1 + thoughts-backend/api/src/error/adapter.rs | 5 + thoughts-backend/api/src/extractor/auth.rs | 29 +++ thoughts-backend/api/src/extractor/mod.rs | 2 + thoughts-backend/api/src/routers/feed.rs | 39 +++ thoughts-backend/api/src/routers/mod.rs | 6 + thoughts-backend/api/src/routers/thought.rs | 87 +++++++ thoughts-backend/api/src/routers/user.rs | 113 +++++++- thoughts-backend/app/src/error/user.rs | 10 + .../app/src/persistence/follow.rs | 46 ++++ thoughts-backend/app/src/persistence/mod.rs | 2 + .../app/src/persistence/thought.rs | 67 +++++ thoughts-backend/app/src/persistence/user.rs | 10 + thoughts-backend/common/Cargo.toml | 1 + thoughts-backend/common/src/lib.rs | 43 +++- thoughts-backend/doc/src/feed.rs | 10 + thoughts-backend/doc/src/lib.rs | 10 +- thoughts-backend/doc/src/thought.rs | 18 ++ thoughts-backend/doc/src/user.rs | 19 +- thoughts-backend/models/src/params/thought.rs | 8 +- .../models/src/schemas/thought.rs | 45 +++- thoughts-backend/models/src/schemas/user.rs | 4 +- thoughts-backend/tests/api/feed.rs | 63 +++++ thoughts-backend/tests/api/follow.rs | 33 +++ thoughts-backend/tests/api/main.rs | 29 +++ thoughts-backend/tests/api/mod.rs | 33 +-- thoughts-backend/tests/api/thought.rs | 36 +++ thoughts-backend/utils/src/testing/api/mod.rs | 12 + thoughts-backend/utils/src/testing/mod.rs | 2 +- thoughts-frontend/app/page.tsx | 243 +++++++++++------- 31 files changed, 872 insertions(+), 155 deletions(-) create mode 100644 thoughts-backend/api/src/extractor/auth.rs create mode 100644 thoughts-backend/api/src/routers/feed.rs create mode 100644 thoughts-backend/api/src/routers/thought.rs create mode 100644 thoughts-backend/app/src/persistence/follow.rs create mode 100644 thoughts-backend/app/src/persistence/thought.rs create mode 100644 thoughts-backend/doc/src/feed.rs create mode 100644 thoughts-backend/doc/src/thought.rs create mode 100644 thoughts-backend/tests/api/feed.rs create mode 100644 thoughts-backend/tests/api/follow.rs create mode 100644 thoughts-backend/tests/api/main.rs create mode 100644 thoughts-backend/tests/api/thought.rs diff --git a/thoughts-backend/Cargo.lock b/thoughts-backend/Cargo.lock index 5c1723a..02e3b57 100644 --- a/thoughts-backend/Cargo.lock +++ b/thoughts-backend/Cargo.lock @@ -642,6 +642,7 @@ name = "common" version = "0.1.0" dependencies = [ "sea-orm", + "sea-query", "serde", "utoipa", ] diff --git a/thoughts-backend/Cargo.toml b/thoughts-backend/Cargo.toml index 9d9192a..fd42018 100644 --- a/thoughts-backend/Cargo.toml +++ b/thoughts-backend/Cargo.toml @@ -17,6 +17,7 @@ members = ["api", "app", "doc", "models", "migration", "utils"] axum = { version = "0.8.4", default-features = false } tower = { version = "0.5.2", default-features = false } sea-orm = { version = "1.1.12" } +sea-query = { version = "0.32.6" } # Added sea-query dependency serde = { version = "1.0.219", features = ["derive"] } serde_json = { version = "1.0.140" } tracing = "0.1.41" diff --git a/thoughts-backend/api/src/error/adapter.rs b/thoughts-backend/api/src/error/adapter.rs index af24d12..de7b6ec 100644 --- a/thoughts-backend/api/src/error/adapter.rs +++ b/thoughts-backend/api/src/error/adapter.rs @@ -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, } } } diff --git a/thoughts-backend/api/src/extractor/auth.rs b/thoughts-backend/api/src/extractor/auth.rs new file mode 100644 index 0000000..1825b51 --- /dev/null +++ b/thoughts-backend/api/src/extractor/auth.rs @@ -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 for AuthUser { + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts( + _parts: &mut Parts, + _state: &AppState, + ) -> Result { + // For now, we'll just return a hardcoded user. + // In a real implementation, you would: + // 1. Extract the `Authorization: Bearer ` 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. + } +} diff --git a/thoughts-backend/api/src/extractor/mod.rs b/thoughts-backend/api/src/extractor/mod.rs index ab46a6e..7d3a6a1 100644 --- a/thoughts-backend/api/src/extractor/mod.rs +++ b/thoughts-backend/api/src/extractor/mod.rs @@ -1,5 +1,7 @@ +mod auth; mod json; mod valid; +pub use auth::AuthUser; pub use json::Json; pub use valid::Valid; diff --git a/thoughts-backend/api/src/routers/feed.rs b/thoughts-backend/api/src/routers/feed.rs new file mode 100644 index 0000000..b3dba6b --- /dev/null +++ b/thoughts-backend/api/src/routers/feed.rs @@ -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, + auth_user: AuthUser, +) -> Result { + 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 = thoughts_with_authors + .into_iter() + .map(ThoughtSchema::from) + .collect(); + + Ok(Json(ThoughtListSchema::from(thoughts_schema))) +} + +pub fn create_feed_router() -> Router { + Router::new().route("/", get(feed_get)) +} diff --git a/thoughts-backend/api/src/routers/mod.rs b/thoughts-backend/api/src/routers/mod.rs index f67a55f..56a96bd 100644 --- a/thoughts-backend/api/src/routers/mod.rs +++ b/thoughts-backend/api/src/routers/mod.rs @@ -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) } diff --git a/thoughts-backend/api/src/routers/thought.rs b/thoughts-backend/api/src/routers/thought.rs new file mode 100644 index 0000000..2477653 --- /dev/null +++ b/thoughts-backend/api/src/routers/thought.rs @@ -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, + auth_user: AuthUser, + Valid(Json(params)): Valid>, +) -> Result { + 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, + auth_user: AuthUser, + Path(id): Path, +) -> Result { + 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 { + Router::new() + .route("/", post(thoughts_post)) + .route("/{id}", delete(thoughts_delete)) +} diff --git a/thoughts-backend/api/src/routers/user.rs b/thoughts-backend/api/src/routers/user.rs index 968c72a..7370294 100644 --- a/thoughts-backend/api/src/routers/user.rs +++ b/thoughts-backend/api/src/routers/user.rs @@ -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, + Path(username): Path, +) -> Result { + 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 = 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, + auth_user: AuthUser, + Path(username): Path, +) -> Result { + 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, + auth_user: AuthUser, + Path(username): Path, +) -> Result { + 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 { 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), + ) } diff --git a/thoughts-backend/app/src/error/user.rs b/thoughts-backend/app/src/error/user.rs index fd77350..42f93af 100644 --- a/thoughts-backend/app/src/error/user.rs +++ b/thoughts-backend/app/src/error/user.rs @@ -1,12 +1,22 @@ #[derive(Debug)] pub enum UserError { NotFound, + NotFollowing, + Forbidden, + UsernameTaken, + AlreadyFollowing, + Internal(String), } impl std::fmt::Display for UserError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { UserError::NotFound => write!(f, "User not found"), + UserError::NotFollowing => write!(f, "You are not following this user"), + UserError::Forbidden => write!(f, "You do not have permission to perform this action"), + UserError::UsernameTaken => write!(f, "Username is already taken"), + UserError::AlreadyFollowing => write!(f, "You are already following this user"), + UserError::Internal(msg) => write!(f, "Internal server error: {}", msg), } } } diff --git a/thoughts-backend/app/src/persistence/follow.rs b/thoughts-backend/app/src/persistence/follow.rs new file mode 100644 index 0000000..4f9dffb --- /dev/null +++ b/thoughts-backend/app/src/persistence/follow.rs @@ -0,0 +1,46 @@ +use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set}; + +use crate::error::UserError; +use models::domains::follow; + +pub async fn follow_user(db: &DbConn, follower_id: i32, followee_id: i32) -> Result<(), DbErr> { + if follower_id == followee_id { + return Err(DbErr::Custom("Users cannot follow themselves".to_string())); + } + + let follow = follow::ActiveModel { + follower_id: Set(follower_id), + followed_id: Set(followee_id), + }; + + follow.save(db).await?; + Ok(()) +} + +pub async fn unfollow_user( + db: &DbConn, + follower_id: i32, + followee_id: i32, +) -> Result<(), UserError> { + let deleted_result = follow::Entity::delete_many() + .filter(follow::Column::FollowerId.eq(follower_id)) + .filter(follow::Column::FollowedId.eq(followee_id)) + .exec(db) + .await + .map_err(|e| UserError::Internal(e.to_string()))?; + + if deleted_result.rows_affected == 0 { + return Err(UserError::NotFollowing); + } + + Ok(()) +} + +pub async fn get_followed_ids(db: &DbConn, user_id: i32) -> Result, DbErr> { + let followed_users = follow::Entity::find() + .filter(follow::Column::FollowerId.eq(user_id)) + .all(db) + .await?; + + Ok(followed_users.into_iter().map(|f| f.followed_id).collect()) +} diff --git a/thoughts-backend/app/src/persistence/mod.rs b/thoughts-backend/app/src/persistence/mod.rs index 22d12a3..81711a1 100644 --- a/thoughts-backend/app/src/persistence/mod.rs +++ b/thoughts-backend/app/src/persistence/mod.rs @@ -1 +1,3 @@ +pub mod follow; +pub mod thought; pub mod user; diff --git a/thoughts-backend/app/src/persistence/thought.rs b/thoughts-backend/app/src/persistence/thought.rs new file mode 100644 index 0000000..bc7a3f8 --- /dev/null +++ b/thoughts-backend/app/src/persistence/thought.rs @@ -0,0 +1,67 @@ +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, QueryFilter, QueryOrder, + QuerySelect, RelationTrait, Set, +}; + +use models::{ + domains::{thought, user}, + params::thought::CreateThoughtParams, + schemas::thought::ThoughtWithAuthor, +}; + +use crate::error::UserError; + +pub async fn create_thought( + db: &DbConn, + author_id: i32, + params: CreateThoughtParams, +) -> Result { + thought::ActiveModel { + author_id: Set(author_id), + content: Set(params.content), + ..Default::default() + } + .insert(db) + .await +} + +pub async fn get_thought(db: &DbConn, thought_id: i32) -> Result, DbErr> { + thought::Entity::find_by_id(thought_id).one(db).await +} + +pub async fn delete_thought(db: &DbConn, thought_id: i32) -> Result<(), DbErr> { + thought::Entity::delete_by_id(thought_id).exec(db).await?; + Ok(()) +} + +pub async fn get_thoughts_by_user( + db: &DbConn, + user_id: i32, +) -> Result, DbErr> { + thought::Entity::find() + .column_as(user::Column::Username, "author_username") + .join(JoinType::InnerJoin, thought::Relation::User.def().rev()) + .filter(thought::Column::AuthorId.eq(user_id)) + .order_by_desc(thought::Column::CreatedAt) + .into_model::() + .all(db) + .await +} + +pub async fn get_feed_for_user( + db: &DbConn, + followed_ids: Vec, +) -> Result, UserError> { + if followed_ids.is_empty() { + return Ok(vec![]); + } + thought::Entity::find() + .column_as(user::Column::Username, "author_username") + .join(JoinType::InnerJoin, thought::Relation::User.def().rev()) + .filter(thought::Column::AuthorId.is_in(followed_ids)) + .order_by_desc(thought::Column::CreatedAt) + .into_model::() + .all(db) + .await + .map_err(|e| UserError::Internal(e.to_string())) +} diff --git a/thoughts-backend/app/src/persistence/user.rs b/thoughts-backend/app/src/persistence/user.rs index ba39bd9..a3f7a9e 100644 --- a/thoughts-backend/app/src/persistence/user.rs +++ b/thoughts-backend/app/src/persistence/user.rs @@ -26,3 +26,13 @@ pub async fn search_users(db: &DbConn, query: UserQuery) -> Result Result, DbErr> { user::Entity::find_by_id(id).one(db).await } + +pub async fn get_user_by_username( + db: &DbConn, + username: &str, +) -> Result, DbErr> { + user::Entity::find() + .filter(user::Column::Username.eq(username)) + .one(db) + .await +} diff --git a/thoughts-backend/common/Cargo.toml b/thoughts-backend/common/Cargo.toml index 3ea2c1f..3891a21 100644 --- a/thoughts-backend/common/Cargo.toml +++ b/thoughts-backend/common/Cargo.toml @@ -7,3 +7,4 @@ edition = "2021" serde = { workspace = true } utoipa = { workspace = true } sea-orm = { workspace = true } +sea-query = { workspace = true } diff --git a/thoughts-backend/common/src/lib.rs b/thoughts-backend/common/src/lib.rs index a01f8d8..9966fab 100644 --- a/thoughts-backend/common/src/lib.rs +++ b/thoughts-backend/common/src/lib.rs @@ -1,10 +1,12 @@ use sea_orm::prelude::DateTimeWithTimeZone; +use sea_orm::TryGetError; +use sea_orm::{sea_query::ColumnType, sea_query::Value, sea_query::ValueType, TryGetable}; +use sea_query::ValueTypeErr; use serde::Serialize; use utoipa::ToSchema; -// Wrapper type for DateTimeWithTimeZone #[derive(Serialize, ToSchema)] -#[schema(example = "2025-09-05T12:34:56Z")] // Example for OpenAPI +#[schema(example = "2025-09-05T12:34:56Z")] pub struct DateTimeWithTimeZoneWrapper(String); impl From for DateTimeWithTimeZoneWrapper { @@ -12,3 +14,40 @@ impl From for DateTimeWithTimeZoneWrapper { DateTimeWithTimeZoneWrapper(value.to_rfc3339()) } } + +impl TryGetable for DateTimeWithTimeZoneWrapper { + fn try_get_by( + res: &sea_orm::QueryResult, + index: I, + ) -> Result { + let value: String = res.try_get_by(index)?; + Ok(DateTimeWithTimeZoneWrapper(value)) + } + + fn try_get(res: &sea_orm::QueryResult, pre: &str, col: &str) -> Result { + let value: String = res.try_get(pre, col)?; + Ok(DateTimeWithTimeZoneWrapper(value)) + } +} + +impl ValueType for DateTimeWithTimeZoneWrapper { + fn try_from(v: Value) -> Result { + if let Value::String(Some(string)) = v { + Ok(DateTimeWithTimeZoneWrapper(*string)) + } else { + Err(ValueTypeErr) + } + } + + fn array_type() -> sea_query::ArrayType { + sea_query::ArrayType::String + } + + fn column_type() -> ColumnType { + ColumnType::String(sea_query::StringLen::Max) + } + + fn type_name() -> String { + "DateTimeWithTimeZoneWrapper".to_string() + } +} diff --git a/thoughts-backend/doc/src/feed.rs b/thoughts-backend/doc/src/feed.rs new file mode 100644 index 0000000..3e70582 --- /dev/null +++ b/thoughts-backend/doc/src/feed.rs @@ -0,0 +1,10 @@ +use api::{models::ApiErrorResponse, routers::feed::*}; +use models::schemas::thought::{ThoughtListSchema, ThoughtSchema}; +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths(feed_get), + components(schemas(ThoughtSchema, ThoughtListSchema, ApiErrorResponse)) +)] +pub(super) struct FeedApi; diff --git a/thoughts-backend/doc/src/lib.rs b/thoughts-backend/doc/src/lib.rs index 5ab5603..3186e87 100644 --- a/thoughts-backend/doc/src/lib.rs +++ b/thoughts-backend/doc/src/lib.rs @@ -3,7 +3,9 @@ use utoipa::OpenApi; use utoipa_scalar::{Scalar, Servable as ScalarServable}; use utoipa_swagger_ui::SwaggerUi; +mod feed; mod root; +mod thought; mod user; #[derive(OpenApi)] @@ -11,11 +13,15 @@ mod user; nest( (path = "/", api = root::RootApi), (path = "/users", api = user::UserApi), + (path = "/thoughts", api = thought::ThoughtApi), + (path = "/feed", api = feed::FeedApi), ), tags( (name = "root", description = "Root API"), - (name = "user", description = "User API"), - ) + (name = "user", description = "User & Social API"), + (name = "thought", description = "Thoughts API"), + (name = "feed", description = "Feed API"), + ), )] struct _ApiDoc; diff --git a/thoughts-backend/doc/src/thought.rs b/thoughts-backend/doc/src/thought.rs new file mode 100644 index 0000000..574b793 --- /dev/null +++ b/thoughts-backend/doc/src/thought.rs @@ -0,0 +1,18 @@ +use api::{ + models::{ApiErrorResponse, ParamsErrorResponse}, + routers::thought::*, +}; +use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema}; +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths(thoughts_post, thoughts_delete), + components(schemas( + CreateThoughtParams, + ThoughtSchema, + ApiErrorResponse, + ParamsErrorResponse + )) +)] +pub(super) struct ThoughtApi; diff --git a/thoughts-backend/doc/src/user.rs b/thoughts-backend/doc/src/user.rs index bb9d9e4..3333244 100644 --- a/thoughts-backend/doc/src/user.rs +++ b/thoughts-backend/doc/src/user.rs @@ -1,18 +1,29 @@ use utoipa::OpenApi; -use models::params::user::CreateUserParams; -use models::schemas::user::{UserListSchema, UserSchema}; - use api::models::{ApiErrorResponse, ParamsErrorResponse}; use api::routers::user::*; +use models::params::user::CreateUserParams; +use models::schemas::{ + thought::{ThoughtListSchema, ThoughtSchema}, + user::{UserListSchema, UserSchema}, +}; #[derive(OpenApi)] #[openapi( - paths(users_get, users_id_get, users_post), + paths( + users_get, + users_id_get, + users_post, + user_thoughts_get, + user_follow_post, + user_follow_delete + ), components(schemas( CreateUserParams, UserListSchema, UserSchema, + ThoughtSchema, + ThoughtListSchema, ApiErrorResponse, ParamsErrorResponse, )) diff --git a/thoughts-backend/models/src/params/thought.rs b/thoughts-backend/models/src/params/thought.rs index 0e2e6e8..e1c0953 100644 --- a/thoughts-backend/models/src/params/thought.rs +++ b/thoughts-backend/models/src/params/thought.rs @@ -4,8 +4,10 @@ use validator::Validate; #[derive(Deserialize, Validate, ToSchema)] pub struct CreateThoughtParams { - pub author_id: i32, - - #[validate(length(min = 1, max = 128))] + #[validate(length( + min = 1, + max = 128, + message = "Content must be between 1 and 128 characters" + ))] pub content: String, } diff --git a/thoughts-backend/models/src/schemas/thought.rs b/thoughts-backend/models/src/schemas/thought.rs index 55bd5c7..e226f34 100644 --- a/thoughts-backend/models/src/schemas/thought.rs +++ b/thoughts-backend/models/src/schemas/thought.rs @@ -1,23 +1,26 @@ -use crate::domains::thought; +use crate::domains::{thought, user}; use common::DateTimeWithTimeZoneWrapper; +use sea_orm::FromQueryResult; use serde::Serialize; use utoipa::ToSchema; -#[derive(Serialize, ToSchema)] +#[derive(Serialize, ToSchema, FromQueryResult)] pub struct ThoughtSchema { pub id: i32, - pub author_id: i32, + #[schema(example = "frutiger")] + pub author_username: String, + #[schema(example = "This is my first thought! #welcome")] pub content: String, pub created_at: DateTimeWithTimeZoneWrapper, } -impl From for ThoughtSchema { - fn from(model: thought::Model) -> Self { +impl ThoughtSchema { + pub fn from_models(thought: &thought::Model, author: &user::Model) -> Self { Self { - id: model.id, - author_id: model.author_id, - content: model.content, - created_at: model.created_at.into(), + id: thought.id, + author_username: author.username.clone(), + content: thought.content.clone(), + created_at: thought.created_at.into(), } } } @@ -27,10 +30,28 @@ pub struct ThoughtListSchema { pub thoughts: Vec, } -impl From> for ThoughtListSchema { - fn from(models: Vec) -> Self { +impl From> for ThoughtListSchema { + fn from(thoughts: Vec) -> Self { + Self { thoughts } + } +} + +#[derive(Debug, FromQueryResult)] +pub struct ThoughtWithAuthor { + pub id: i32, + pub content: String, + pub created_at: sea_orm::prelude::DateTimeWithTimeZone, + pub author_id: i32, + pub author_username: String, +} + +impl From for ThoughtSchema { + fn from(model: ThoughtWithAuthor) -> Self { Self { - thoughts: models.into_iter().map(ThoughtSchema::from).collect(), + id: model.id, + author_username: model.author_username, + content: model.content, + created_at: model.created_at.into(), } } } diff --git a/thoughts-backend/models/src/schemas/user.rs b/thoughts-backend/models/src/schemas/user.rs index 5ed77f8..268cbb5 100644 --- a/thoughts-backend/models/src/schemas/user.rs +++ b/thoughts-backend/models/src/schemas/user.rs @@ -5,14 +5,14 @@ use crate::domains::user; #[derive(Serialize, ToSchema)] pub struct UserSchema { - pub id: u32, + pub id: i32, pub username: String, } impl From for UserSchema { fn from(user: user::Model) -> Self { Self { - id: user.id as u32, + id: user.id, username: user.username, } } diff --git a/thoughts-backend/tests/api/feed.rs b/thoughts-backend/tests/api/feed.rs new file mode 100644 index 0000000..87f88d9 --- /dev/null +++ b/thoughts-backend/tests/api/feed.rs @@ -0,0 +1,63 @@ +use super::main::{create_test_user, setup}; +use axum::http::StatusCode; +use http_body_util::BodyExt; +use serde_json::json; +use utils::testing::{make_get_request, make_post_request}; + +#[tokio::test] +async fn test_feed_and_user_thoughts() { + let app = setup().await; + create_test_user(&app.db, "user1").await; // AuthUser is ID 1 + create_test_user(&app.db, "user2").await; + create_test_user(&app.db, "user3").await; + + // As user1, post a thought + let body = json!({ "content": "A thought from user1" }).to_string(); + make_post_request(app.router.clone(), "/thoughts", body).await; + + // As a different "user", create thoughts for user2 and user3 (we cheat here since auth is hardcoded) + app::persistence::thought::create_thought( + &app.db, + 2, + models::params::thought::CreateThoughtParams { + content: "user2 was here".to_string(), + }, + ) + .await + .unwrap(); + app::persistence::thought::create_thought( + &app.db, + 3, + models::params::thought::CreateThoughtParams { + content: "user3 checking in".to_string(), + }, + ) + .await + .unwrap(); + + // 1. Get thoughts for user2 - should only see their thought + let response = make_get_request(app.router.clone(), "/users/user2/thoughts").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(); + assert_eq!(v["thoughts"].as_array().unwrap().len(), 1); + assert_eq!(v["thoughts"][0]["content"], "user2 was here"); + + // 2. user1's feed is initially empty + let response = make_get_request(app.router.clone(), "/feed").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(); + assert!(v["thoughts"].as_array().unwrap().is_empty()); + + // 3. user1 follows user2 + make_post_request(app.router.clone(), "/users/user2/follow", "".to_string()).await; + + // 4. user1's feed now has user2's thought + let response = make_get_request(app.router.clone(), "/feed").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(); + assert_eq!(v["thoughts"].as_array().unwrap().len(), 1); + assert_eq!(v["thoughts"][0]["author_username"], "user2"); +} diff --git a/thoughts-backend/tests/api/follow.rs b/thoughts-backend/tests/api/follow.rs new file mode 100644 index 0000000..f9d87da --- /dev/null +++ b/thoughts-backend/tests/api/follow.rs @@ -0,0 +1,33 @@ +use super::main::{create_test_user, setup}; +use axum::http::StatusCode; +use utils::testing::{make_delete_request, make_post_request}; + +#[tokio::test] +async fn test_follow_endpoints() { + let app = setup().await; + create_test_user(&app.db, "user1").await; // AuthUser is ID 1 + create_test_user(&app.db, "user2").await; + + // 1. user1 follows user2 + let response = + make_post_request(app.router.clone(), "/users/user2/follow", "".to_string()).await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + // 2. user1 tries to follow user2 again (should fail) + let response = + make_post_request(app.router.clone(), "/users/user2/follow", "".to_string()).await; + assert_eq!(response.status(), StatusCode::CONFLICT); + + // 3. user1 tries to follow a non-existent user + let response = + make_post_request(app.router.clone(), "/users/nobody/follow", "".to_string()).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // 4. user1 unfollows user2 + let response = make_delete_request(app.router.clone(), "/users/user2/follow").await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + // 5. user1 tries to unfollow user2 again (should fail) + let response = make_delete_request(app.router.clone(), "/users/user2/follow").await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/thoughts-backend/tests/api/main.rs b/thoughts-backend/tests/api/main.rs new file mode 100644 index 0000000..f59cb09 --- /dev/null +++ b/thoughts-backend/tests/api/main.rs @@ -0,0 +1,29 @@ +use api::setup_router; +use app::persistence::user::create_user; +use axum::Router; +use models::params::user::CreateUserParams; +use sea_orm::DatabaseConnection; +use utils::testing::setup_test_db; + +pub struct TestApp { + pub router: Router, + pub db: DatabaseConnection, +} + +pub async fn setup() -> TestApp { + let db = setup_test_db("sqlite::memory:") + .await + .expect("Failed to set up test db"); + let router = setup_router(db.clone()); + TestApp { router, db } +} + +// Helper to create users for tests +pub async fn create_test_user(db: &DatabaseConnection, username: &str) { + let params = CreateUserParams { + username: username.to_string(), + }; + create_user(db, params) + .await + .expect("Failed to create test user"); +} diff --git a/thoughts-backend/tests/api/mod.rs b/thoughts-backend/tests/api/mod.rs index 0da72bd..fef893f 100644 --- a/thoughts-backend/tests/api/mod.rs +++ b/thoughts-backend/tests/api/mod.rs @@ -1,30 +1,5 @@ -use api::setup_router; -use utils::testing::setup_test_db; - -mod root; +mod feed; +mod follow; +mod main; +mod thought; mod user; - -use root::*; -use user::*; - -#[tokio::test] -async fn root_main() { - let db = setup_test_db("sqlite::root?mode=memory&cache=shared") - .await - .expect("Set up db failed!"); - - let app = setup_router(db); - test_root(app).await; -} - -#[tokio::test] -async fn user_main() { - let db = setup_test_db("sqlite::user?mode=memory&cache=shared") - .await - .expect("Set up db failed!"); - - let app = setup_router(db); - test_post_users(app.clone()).await; - test_post_users_error(app.clone()).await; - test_get_users(app).await; -} diff --git a/thoughts-backend/tests/api/thought.rs b/thoughts-backend/tests/api/thought.rs new file mode 100644 index 0000000..449ae7b --- /dev/null +++ b/thoughts-backend/tests/api/thought.rs @@ -0,0 +1,36 @@ +use super::main::{create_test_user, setup}; +use axum::http::StatusCode; +use http_body_util::BodyExt; +use serde_json::json; +use utils::testing::{make_delete_request, make_post_request}; + +#[tokio::test] +async fn test_thought_endpoints() { + let app = setup().await; + create_test_user(&app.db, "user1").await; // AuthUser is ID 1 + create_test_user(&app.db, "user2").await; // Other user is ID 2 + + // 1. Post a new thought as user 1 + let body = json!({ "content": "My first thought!" }).to_string(); + let response = make_post_request(app.router.clone(), "/thoughts", body).await; + assert_eq!(response.status(), StatusCode::CREATED); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["content"], "My first thought!"); + assert_eq!(v["author_username"], "user1"); + let thought_id = v["id"].as_i64().unwrap(); + + // 2. Post a thought with invalid content + let body = json!({ "content": "" }).to_string(); // Too short + let response = make_post_request(app.router.clone(), "/thoughts", body).await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + // 3. Attempt to delete another user's thought (user1 tries to delete a non-existent thought, but let's pretend it's user2's) + let response = make_delete_request(app.router.clone(), &format!("/thoughts/999")).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // 4. Delete the thought created in step 1 + let response = + make_delete_request(app.router.clone(), &format!("/thoughts/{}", thought_id)).await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); +} diff --git a/thoughts-backend/utils/src/testing/api/mod.rs b/thoughts-backend/utils/src/testing/api/mod.rs index ed4a949..dd91714 100644 --- a/thoughts-backend/utils/src/testing/api/mod.rs +++ b/thoughts-backend/utils/src/testing/api/mod.rs @@ -19,3 +19,15 @@ pub async fn make_post_request(app: Router, url: &str, body: String) -> Response .await .unwrap() } + +pub async fn make_delete_request(app: Router, url: &str) -> Response { + app.oneshot( + Request::builder() + .method("DELETE") + .uri(url) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() +} diff --git a/thoughts-backend/utils/src/testing/mod.rs b/thoughts-backend/utils/src/testing/mod.rs index c831f72..ce3986c 100644 --- a/thoughts-backend/utils/src/testing/mod.rs +++ b/thoughts-backend/utils/src/testing/mod.rs @@ -1,5 +1,5 @@ mod api; mod db; -pub use api::{make_get_request, make_post_request}; +pub use api::{make_delete_request, make_get_request, make_post_request}; pub use db::setup_test_db; diff --git a/thoughts-frontend/app/page.tsx b/thoughts-frontend/app/page.tsx index 21b686d..d6743ed 100644 --- a/thoughts-frontend/app/page.tsx +++ b/thoughts-frontend/app/page.tsx @@ -1,103 +1,158 @@ -import Image from "next/image"; +"use client"; + +import { useState, useEffect, FormEvent } from "react"; + +interface Thought { + id: number; + author_id: number; + content: string; + created_at: string; +} export default function Home() { - return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
+ // State to store the list of thoughts for the feed + const [thoughts, setThoughts] = useState([]); + // State for the content of the new thought being typed + const [newThoughtContent, setNewThoughtContent] = useState(""); + // State to manage loading status + const [isLoading, setIsLoading] = useState(true); + // State to hold any potential errors during API calls + const [error, setError] = useState(null); -
- { + try { + setError(null); + const response = await fetch("http://localhost:8000/api/feed"); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + // The API returns { thoughts: [...] }, so we access the nested array + setThoughts(data.thoughts || []); + } catch (e: unknown) { + console.error("Failed to fetch feed:", e); + setError( + "Could not load the feed. The backend might be busy. Please try refreshing." + ); + } finally { + setIsLoading(false); + } + }; + + // useEffect hook to fetch the feed when the component first loads + useEffect(() => { + fetchFeed(); + }, []); + + // Handler for submitting the new thought form + const handleSubmitThought = async (e: FormEvent) => { + e.preventDefault(); + if (!newThoughtContent.trim()) return; // Prevent empty posts + + try { + const response = await fetch("http://localhost:8000/api/thoughts", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + // We are hardcoding author_id: 1 as we don't have auth yet + body: JSON.stringify({ content: newThoughtContent, author_id: 1 }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + // Clear the input box + setNewThoughtContent(""); + // Refresh the feed to show the new post + fetchFeed(); + } catch (e: unknown) { + console.error("Failed to post thought:", e); + setError("Failed to post your thought. Please try again."); + } + }; + + return ( +
+
+ {/* Header */} +
+

- Vercel logomark +

+ Your space on the decentralized web. +

+

+ + {/* New Thought Form */} +
+
+