refactor(users): content negotiation at GET /users/{username}; move lookup_handler; rename get_me_following

This commit is contained in:
2026-05-14 21:25:49 +02:00
parent abc5f2b936
commit d1f72c8308
2 changed files with 157 additions and 14 deletions

View File

@@ -6,13 +6,15 @@ use crate::{
}; };
use api_types::{ use api_types::{
requests::{PaginationQuery, UpdateProfileRequest}, requests::{PaginationQuery, UpdateProfileRequest},
responses::{ErrorResponse, UserResponse}, responses::{ErrorResponse, RemoteActorResponse, UserResponse},
}; };
use application::use_cases::feed::list_users; use application::use_cases::feed::list_users;
use application::use_cases::profile::{get_user_by_username, update_profile}; use application::use_cases::profile::{get_user_by_username, update_profile};
use application::use_cases::search::search_users; use application::use_cases::search::search_users;
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
http::{header, HeaderMap},
response::{IntoResponse, Response},
Json, Json,
}; };
@@ -28,16 +30,28 @@ pub async fn get_user(
State(s): State<AppState>, State(s): State<AppState>,
Path(username): Path<String>, Path(username): Path<String>,
OptionalAuthUser(viewer): OptionalAuthUser, OptionalAuthUser(viewer): OptionalAuthUser,
) -> Result<Json<UserResponse>, ApiError> { headers: HeaderMap,
) -> Result<Response, ApiError> {
let user = get_user_by_username(&*s.users, &username).await?; 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 { } else {
false let is_followed = if let Some(viewer_id) = viewer {
}; s.follows.find(&viewer_id, &user.id).await?.is_some()
let mut resp = to_user_response(&user); } else {
resp.is_followed_by_viewer = is_followed; false
Ok(Json(resp)) };
let mut resp = to_user_response(&user);
resp.is_followed_by_viewer = is_followed;
Ok(Json(resp).into_response())
}
} }
#[utoipa::path( #[utoipa::path(
@@ -92,7 +106,7 @@ pub async fn get_me(
Ok(Json(to_user_response(&user))) Ok(Json(to_user_response(&user)))
} }
pub async fn get_me_following_list( pub async fn get_me_following(
State(s): State<AppState>, State(s): State<AppState>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
Query(q): Query<PaginationQuery>, Query(q): Query<PaginationQuery>,
@@ -170,3 +184,135 @@ pub async fn get_user_count(
let count = s.users.count().await?; let count = s.users.count().await?;
Ok(Json(serde_json::json!({ "count": count }))) Ok(Json(serde_json::json!({ "count": count })))
} }
#[derive(serde::Deserialize)]
pub struct LookupQuery {
pub handle: String,
}
pub async fn lookup_handler(
State(s): State<AppState>,
Query(q): Query<LookupQuery>,
) -> Result<Json<RemoteActorResponse>, 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<GeneratedToken, DomainError> {
Err(DomainError::Internal("noop".into()))
}
fn validate_token(&self, _token: &str) -> Result<UserId, DomainError> {
Err(DomainError::Unauthorized)
}
}
struct NoOpHasher;
#[async_trait]
impl PasswordHasher for NoOpHasher {
async fn hash(&self, _plain: &str) -> Result<PasswordHash, DomainError> {
Err(DomainError::Internal("noop".into()))
}
async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result<bool, DomainError> {
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);
}
}

View File

@@ -20,10 +20,7 @@ pub fn router() -> Router<AppState> {
.patch(users::patch_profile) .patch(users::patch_profile)
.put(users::patch_profile), .put(users::patch_profile),
) )
.route( .route("/users/me/following-list", get(users::get_me_following))
"/users/me/following-list",
get(users::get_me_following_list),
)
.route("/users/me/top-friends", put(social::put_top_friends)) .route("/users/me/top-friends", put(social::put_top_friends))
// /users/{username} is owned by the AP router (returns AP actor JSON for federation). // /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. // The REST user profile lives at /users/{username}/profile to avoid the conflict.