From d1f72c830842e6a678711daa7a8378dc36a6e49a Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 14 May 2026 21:25:49 +0200 Subject: [PATCH] refactor(users): content negotiation at GET /users/{username}; move lookup_handler; rename get_me_following --- crates/presentation/src/handlers/users.rs | 166 ++++++++++++++++++++-- crates/presentation/src/routes.rs | 5 +- 2 files changed, 157 insertions(+), 14 deletions(-) diff --git a/crates/presentation/src/handlers/users.rs b/crates/presentation/src/handlers/users.rs index 6330b4e..77b38c2 100644 --- a/crates/presentation/src/handlers/users.rs +++ b/crates/presentation/src/handlers/users.rs @@ -6,13 +6,15 @@ use crate::{ }; use api_types::{ requests::{PaginationQuery, UpdateProfileRequest}, - responses::{ErrorResponse, UserResponse}, + responses::{ErrorResponse, RemoteActorResponse, UserResponse}, }; use application::use_cases::feed::list_users; use application::use_cases::profile::{get_user_by_username, update_profile}; use application::use_cases::search::search_users; use axum::{ extract::{Path, Query, State}, + http::{header, HeaderMap}, + response::{IntoResponse, Response}, Json, }; @@ -28,16 +30,28 @@ pub async fn get_user( State(s): State, Path(username): Path, OptionalAuthUser(viewer): OptionalAuthUser, -) -> Result, ApiError> { + headers: HeaderMap, +) -> Result { let user = get_user_by_username(&*s.users, &username).await?; - let is_followed = if let Some(viewer_id) = viewer { - s.follows.find(&viewer_id, &user.id).await?.is_some() + + let accept = headers + .get(header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if accept.contains("application/activity+json") { + let json = s.federation.actor_json(&user.id).await?; + Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response()) } else { - false - }; - let mut resp = to_user_response(&user); - resp.is_followed_by_viewer = is_followed; - Ok(Json(resp)) + let is_followed = if let Some(viewer_id) = viewer { + s.follows.find(&viewer_id, &user.id).await?.is_some() + } else { + false + }; + let mut resp = to_user_response(&user); + resp.is_followed_by_viewer = is_followed; + Ok(Json(resp).into_response()) + } } #[utoipa::path( @@ -92,7 +106,7 @@ pub async fn get_me( Ok(Json(to_user_response(&user))) } -pub async fn get_me_following_list( +pub async fn get_me_following( State(s): State, AuthUser(uid): AuthUser, Query(q): Query, @@ -170,3 +184,135 @@ pub async fn get_user_count( let count = s.users.count().await?; Ok(Json(serde_json::json!({ "count": count }))) } + +#[derive(serde::Deserialize)] +pub struct LookupQuery { + pub handle: String, +} + +pub async fn lookup_handler( + State(s): State, + Query(q): Query, +) -> Result, ApiError> { + let actor = s.federation.lookup_actor(&q.handle).await?; + Ok(Json(RemoteActorResponse { + handle: actor.handle, + display_name: actor.display_name, + avatar_url: actor.avatar_url, + url: actor.url, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use axum::{ + body::Body, + http::{header, Request}, + routing::get, + Router, + }; + use domain::{ + errors::DomainError, + ports::{AuthService, GeneratedToken, PasswordHasher}, + testing::TestStore, + value_objects::{PasswordHash, UserId}, + }; + use std::sync::Arc; + use tower::ServiceExt; + + struct NoOpAuth; + impl AuthService for NoOpAuth { + fn generate_token(&self, _uid: &UserId) -> Result { + Err(DomainError::Internal("noop".into())) + } + fn validate_token(&self, _token: &str) -> Result { + Err(DomainError::Unauthorized) + } + } + + struct NoOpHasher; + #[async_trait] + impl PasswordHasher for NoOpHasher { + async fn hash(&self, _plain: &str) -> Result { + Err(DomainError::Internal("noop".into())) + } + async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result { + Ok(false) + } + } + + fn make_state() -> crate::state::AppState { + let store = Arc::new(TestStore::default()); + crate::state::AppState { + users: store.clone(), + thoughts: store.clone(), + likes: store.clone(), + boosts: store.clone(), + follows: store.clone(), + blocks: store.clone(), + tags: store.clone(), + api_keys: store.clone(), + top_friends: store.clone(), + notifications: store.clone(), + remote_actors: store.clone(), + feed: store.clone(), + search: store.clone(), + auth: Arc::new(NoOpAuth), + hasher: Arc::new(NoOpHasher), + events: store.clone(), + federation: store.clone(), + } + } + + fn app() -> Router { + Router::new() + .route("/users/{username}", get(get_user)) + .route("/users/lookup", get(lookup_handler)) + .with_state(make_state()) + } + + #[tokio::test] + async fn get_unknown_user_returns_404() { + let resp = app() + .oneshot( + Request::builder() + .uri("/users/nobody") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 404); + } + + #[tokio::test] + async fn get_user_with_ap_accept_returns_404_when_actor_not_found() { + let resp = app() + .oneshot( + Request::builder() + .uri("/users/nobody") + .header(header::ACCEPT, "application/activity+json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 404); + } + + #[tokio::test] + async fn lookup_unknown_handle_returns_404() { + let resp = app() + .oneshot( + Request::builder() + .uri("/users/lookup?handle=%40alice%40example.com") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 404); + } +} diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index a1f85b6..fe77654 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -20,10 +20,7 @@ pub fn router() -> Router { .patch(users::patch_profile) .put(users::patch_profile), ) - .route( - "/users/me/following-list", - get(users::get_me_following_list), - ) + .route("/users/me/following-list", get(users::get_me_following)) .route("/users/me/top-friends", put(social::put_top_friends)) // /users/{username} is owned by the AP router (returns AP actor JSON for federation). // The REST user profile lives at /users/{username}/profile to avoid the conflict.