From dc9294596248b81e6be077d0813c32346db8bd75 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 6 Sep 2025 22:14:47 +0200 Subject: [PATCH] feat: implement friends API with routes to get friends list and update thought visibility logic --- thoughts-backend/api/src/routers/api_key.rs | 6 +- thoughts-backend/api/src/routers/friends.rs | 24 ++++++ thoughts-backend/api/src/routers/mod.rs | 2 + thoughts-backend/api/src/routers/thought.rs | 37 +++++++- .../app/src/persistence/thought.rs | 32 ++++++- thoughts-backend/app/src/persistence/user.rs | 10 ++- thoughts-backend/doc/src/friends.rs | 12 +++ thoughts-backend/doc/src/lib.rs | 3 + thoughts-backend/doc/src/thought.rs | 2 +- thoughts-backend/tests/api/follow.rs | 38 ++++++++ thoughts-backend/tests/api/thought.rs | 86 +++++++++++++++++++ 11 files changed, 241 insertions(+), 11 deletions(-) create mode 100644 thoughts-backend/api/src/routers/friends.rs create mode 100644 thoughts-backend/doc/src/friends.rs diff --git a/thoughts-backend/api/src/routers/api_key.rs b/thoughts-backend/api/src/routers/api_key.rs index f4f4ec1..c3d135f 100644 --- a/thoughts-backend/api/src/routers/api_key.rs +++ b/thoughts-backend/api/src/routers/api_key.rs @@ -16,7 +16,7 @@ use sea_orm::prelude::Uuid; #[utoipa::path( get, - path = "/me/api-keys", + path = "", responses( (status = 200, description = "List of API keys", body = ApiKeyListSchema), (status = 401, description = "Unauthorized", body = ApiErrorResponse), @@ -36,7 +36,7 @@ async fn get_keys( #[utoipa::path( post, - path = "/me/api-keys", + path = "", request_body = ApiKeyRequest, responses( (status = 201, description = "API key created", body = ApiKeyResponse), @@ -63,7 +63,7 @@ async fn create_key( #[utoipa::path( delete, - path = "/me/api-keys/{key_id}", + path = "/{key_id}", responses( (status = 204, description = "API key deleted"), (status = 401, description = "Unauthorized", body = ApiErrorResponse), diff --git a/thoughts-backend/api/src/routers/friends.rs b/thoughts-backend/api/src/routers/friends.rs new file mode 100644 index 0000000..b6e45a5 --- /dev/null +++ b/thoughts-backend/api/src/routers/friends.rs @@ -0,0 +1,24 @@ +use crate::{error::ApiError, extractor::AuthUser}; +use app::{persistence::user, state::AppState}; +use axum::{extract::State, response::IntoResponse, routing::get, Json, Router}; +use models::schemas::user::UserListSchema; + +#[utoipa::path( + get, + path = "", + responses( + (status = 200, description = "List of authenticated user's friends", body = UserListSchema) + ), + security(("bearer_auth" = [])) +)] +async fn get_friends_list( + State(state): State, + auth_user: AuthUser, +) -> Result { + let friends = user::get_friends(&state.conn, auth_user.id).await?; + Ok(Json(UserListSchema::from(friends))) +} + +pub fn create_friends_router() -> Router { + Router::new().route("/", get(get_friends_list)) +} diff --git a/thoughts-backend/api/src/routers/mod.rs b/thoughts-backend/api/src/routers/mod.rs index caa5178..b305f0a 100644 --- a/thoughts-backend/api/src/routers/mod.rs +++ b/thoughts-backend/api/src/routers/mod.rs @@ -3,6 +3,7 @@ use axum::Router; pub mod api_key; pub mod auth; pub mod feed; +pub mod friends; pub mod root; pub mod tag; pub mod thought; @@ -28,6 +29,7 @@ pub fn create_router(state: AppState) -> Router { .nest("/thoughts", create_thought_router()) .nest("/feed", create_feed_router()) .nest("/tags", tag::create_tag_router()) + .nest("/friends", friends::create_friends_router()) .with_state(state) .layer(cors) } diff --git a/thoughts-backend/api/src/routers/thought.rs b/thoughts-backend/api/src/routers/thought.rs index fbfab5b..f428775 100644 --- a/thoughts-backend/api/src/routers/thought.rs +++ b/thoughts-backend/api/src/routers/thought.rs @@ -2,7 +2,7 @@ use axum::{ extract::{Path, State}, http::StatusCode, response::IntoResponse, - routing::{delete, post}, + routing::{get, post}, Router, }; @@ -16,11 +16,40 @@ use sea_orm::prelude::Uuid; use crate::{ error::ApiError, - extractor::{AuthUser, Json, Valid}, + extractor::{AuthUser, Json, OptionalAuthUser, Valid}, federation, models::{ApiErrorResponse, ParamsErrorResponse}, }; +#[utoipa::path( + get, + path = "/{id}", + params( + ("id" = Uuid, Path, description = "Thought ID") + ), + responses( + (status = 200, description = "Thought found", body = ThoughtSchema), + (status = 404, description = "Not Found", body = ApiErrorResponse) + ) +)] +async fn get_thought_by_id( + State(state): State, + Path(id): Path, + viewer: OptionalAuthUser, +) -> Result { + let viewer_id = viewer.0.map(|u| u.id); + let thought = get_thought(&state.conn, id, viewer_id) + .await? + .ok_or(UserError::NotFound)?; + + let author = app::persistence::user::get_user(&state.conn, thought.author_id) + .await? + .ok_or(UserError::NotFound)?; + + let schema = ThoughtSchema::from_models(&thought, &author); + Ok(Json(schema)) +} + #[utoipa::path( post, path = "", @@ -77,7 +106,7 @@ async fn thoughts_delete( auth_user: AuthUser, Path(id): Path, ) -> Result { - let thought = get_thought(&state.conn, id) + let thought = get_thought(&state.conn, id, Some(auth_user.id)) .await? .ok_or(UserError::NotFound)?; @@ -92,5 +121,5 @@ async fn thoughts_delete( pub fn create_thought_router() -> Router { Router::new() .route("/", post(thoughts_post)) - .route("/{id}", delete(thoughts_delete)) + .route("/{id}", get(get_thought_by_id).delete(thoughts_delete)) } diff --git a/thoughts-backend/app/src/persistence/thought.rs b/thoughts-backend/app/src/persistence/thought.rs index 7b610e0..5e60b07 100644 --- a/thoughts-backend/app/src/persistence/thought.rs +++ b/thoughts-backend/app/src/persistence/thought.rs @@ -45,8 +45,36 @@ pub async fn create_thought( Ok(new_thought) } -pub async fn get_thought(db: &DbConn, thought_id: Uuid) -> Result, DbErr> { - thought::Entity::find_by_id(thought_id).one(db).await +pub async fn get_thought( + db: &DbConn, + thought_id: Uuid, + viewer_id: Option, +) -> Result, DbErr> { + let thought = thought::Entity::find_by_id(thought_id).one(db).await?; + + match thought { + Some(t) => { + if t.visibility == thought::Visibility::Public { + return Ok(Some(t)); + } + + if let Some(viewer) = viewer_id { + if t.author_id == viewer { + return Ok(Some(t)); + } + + if t.visibility == thought::Visibility::FriendsOnly { + let author_friends = follow::get_friend_ids(db, t.author_id).await?; + if author_friends.contains(&viewer) { + return Ok(Some(t)); + } + } + } + + Ok(None) + } + None => Ok(None), + } } pub async fn delete_thought(db: &DbConn, thought_id: Uuid) -> Result<(), DbErr> { diff --git a/thoughts-backend/app/src/persistence/user.rs b/thoughts-backend/app/src/persistence/user.rs index 2dfeea2..80a36b7 100644 --- a/thoughts-backend/app/src/persistence/user.rs +++ b/thoughts-backend/app/src/persistence/user.rs @@ -9,7 +9,7 @@ use models::params::user::{CreateUserParams, UpdateUserParams}; use models::queries::user::UserQuery; use crate::error::UserError; -use crate::persistence::follow::{get_follower_ids, get_following_ids}; +use crate::persistence::follow::{get_follower_ids, get_following_ids, get_friend_ids}; pub async fn create_user( db: &DbConn, @@ -142,6 +142,14 @@ pub async fn get_top_friends(db: &DbConn, user_id: Uuid) -> Result Result, DbErr> { + let friend_ids = get_friend_ids(db, user_id).await?; + if friend_ids.is_empty() { + return Ok(vec![]); + } + get_users_by_ids(db, friend_ids).await +} + pub async fn get_following(db: &DbConn, user_id: Uuid) -> Result, DbErr> { let following_ids = get_following_ids(db, user_id).await?; if following_ids.is_empty() { diff --git a/thoughts-backend/doc/src/friends.rs b/thoughts-backend/doc/src/friends.rs new file mode 100644 index 0000000..1870577 --- /dev/null +++ b/thoughts-backend/doc/src/friends.rs @@ -0,0 +1,12 @@ +use utoipa::OpenApi; + +use api::models::{ApiErrorResponse, ParamsErrorResponse}; +use api::routers::friends::*; +use models::schemas::user::{UserListSchema, UserSchema}; + +#[derive(OpenApi)] +#[openapi( + paths(get_friends_list,), + components(schemas(UserListSchema, ApiErrorResponse, ParamsErrorResponse, UserSchema)) +)] +pub(super) struct FriendsApi; diff --git a/thoughts-backend/doc/src/lib.rs b/thoughts-backend/doc/src/lib.rs index c0da6c3..46234d4 100644 --- a/thoughts-backend/doc/src/lib.rs +++ b/thoughts-backend/doc/src/lib.rs @@ -9,6 +9,7 @@ use utoipa_swagger_ui::SwaggerUi; mod api_key; mod auth; mod feed; +mod friends; mod root; mod tag; mod thought; @@ -24,6 +25,7 @@ mod user; (path = "/thoughts", api = thought::ThoughtApi), (path = "/feed", api = feed::FeedApi), (path = "/tags", api = tag::TagApi), + (path = "/friends", api = friends::FriendsApi), ), tags( (name = "root", description = "Root API"), @@ -32,6 +34,7 @@ mod user; (name = "thought", description = "Thoughts API"), (name = "feed", description = "Feed API"), (name = "tag", description = "Tag Discovery API"), + (name = "friends", description = "Friends API"), ), modifiers(&SecurityAddon), )] diff --git a/thoughts-backend/doc/src/thought.rs b/thoughts-backend/doc/src/thought.rs index 574b793..39a9c8c 100644 --- a/thoughts-backend/doc/src/thought.rs +++ b/thoughts-backend/doc/src/thought.rs @@ -7,7 +7,7 @@ use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( - paths(thoughts_post, thoughts_delete), + paths(thoughts_post, thoughts_delete, get_thought_by_id), components(schemas( CreateThoughtParams, ThoughtSchema, diff --git a/thoughts-backend/tests/api/follow.rs b/thoughts-backend/tests/api/follow.rs index 67ff46a..ce60bb7 100644 --- a/thoughts-backend/tests/api/follow.rs +++ b/thoughts-backend/tests/api/follow.rs @@ -122,3 +122,41 @@ async fn test_follow_lists() { assert_eq!(v["following"].as_array().unwrap().len(), 1); assert_eq!(v["following"][0]["username"], "userB"); } + +#[tokio::test] +async fn test_get_friends_list() { + let app = setup().await; + + let user_a = create_user_with_password(&app.db, "userA", "password123", "a@a.com").await; + let user_b = create_user_with_password(&app.db, "userB", "password123", "b@b.com").await; + let user_c = create_user_with_password(&app.db, "userC", "password123", "c@c.com").await; + + // --- Create relationships --- + // A and B are friends (reciprocal follow) + app::persistence::follow::follow_user(&app.db, user_a.id, user_b.id) + .await + .unwrap(); + app::persistence::follow::follow_user(&app.db, user_b.id, user_a.id) + .await + .unwrap(); + + // A follows C, but C does not follow A back + app::persistence::follow::follow_user(&app.db, user_a.id, user_c.id) + .await + .unwrap(); + + // --- Test as user_a --- + let jwt_a = login_user(app.router.clone(), "userA", "password123").await; + let response = make_jwt_request(app.router.clone(), "/friends", "GET", None, &jwt_a).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 friends_list = v["users"].as_array().unwrap(); + + assert_eq!(friends_list.len(), 1, "User A should only have one friend"); + assert_eq!( + friends_list[0]["username"], "userB", + "User B should be in User A's friend list" + ); +} diff --git a/thoughts-backend/tests/api/thought.rs b/thoughts-backend/tests/api/thought.rs index c1bc099..b6b1626 100644 --- a/thoughts-backend/tests/api/thought.rs +++ b/thoughts-backend/tests/api/thought.rs @@ -163,3 +163,89 @@ async fn test_thought_visibility() { "Unauthenticated guest should see only public posts" ); } + +async fn post_thought_and_get_id( + router: &Router, + content: &str, + visibility: &str, + token: &str, +) -> String { + let body = json!({ "content": content, "visibility": visibility }).to_string(); + let response = make_jwt_request(router.clone(), "/thoughts", "POST", Some(body), token).await; + assert_eq!(response.status(), StatusCode::CREATED); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&body).unwrap(); + v["id"].as_str().unwrap().to_string() +} + +#[tokio::test] +async fn test_get_thought_by_id_visibility() { + let app = setup().await; + let author = create_user_with_password(&app.db, "author", "password123", "a@a.com").await; + let friend = create_user_with_password(&app.db, "friend", "password123", "f@f.com").await; + let _stranger = create_user_with_password(&app.db, "stranger", "password123", "s@s.com").await; + + // Make author and friend follow each other + follow::follow_user(&app.db, author.id, friend.id) + .await + .unwrap(); + follow::follow_user(&app.db, friend.id, author.id) + .await + .unwrap(); + + let author_jwt = login_user(app.router.clone(), "author", "password123").await; + let friend_jwt = login_user(app.router.clone(), "friend", "password123").await; + let stranger_jwt = login_user(app.router.clone(), "stranger", "password123").await; + + // Author posts one of each visibility + let public_id = post_thought_and_get_id(&app.router, "public", "Public", &author_jwt).await; + let friends_id = + post_thought_and_get_id(&app.router, "friends", "FriendsOnly", &author_jwt).await; + let private_id = post_thought_and_get_id(&app.router, "private", "Private", &author_jwt).await; + + // --- Test Assertions --- + + // 1. Public thought + let public_url = format!("/thoughts/{}", public_id); + assert_eq!( + make_get_request(app.router.clone(), &public_url, None) + .await + .status(), + StatusCode::OK, + "Guest should see public thought" + ); + + // 2. Friends-only thought + let friends_url = format!("/thoughts/{}", friends_id); + assert_eq!( + make_jwt_request(app.router.clone(), &friends_url, "GET", None, &friend_jwt) + .await + .status(), + StatusCode::OK, + "Friend should see friends-only thought" + ); + assert_eq!( + make_jwt_request(app.router.clone(), &friends_url, "GET", None, &stranger_jwt) + .await + .status(), + StatusCode::NOT_FOUND, + "Stranger should NOT see friends-only thought" + ); + + // 3. Private thought + let private_url = format!("/thoughts/{}", private_id); + assert_eq!( + make_jwt_request(app.router.clone(), &private_url, "GET", None, &author_jwt) + .await + .status(), + StatusCode::OK, + "Author should see their private thought" + ); + assert_eq!( + make_jwt_request(app.router.clone(), &private_url, "GET", None, &friend_jwt) + .await + .status(), + StatusCode::NOT_FOUND, + "Friend should NOT see private thought" + ); +}