Compare commits

...

10 Commits

Author SHA1 Message Date
908789e639 fix: content negotiation for followers/following — resolve AP router conflict
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 9m28s
test / unit (pull_request) Failing after 11m39s
test / integration (pull_request) Failing after 17m5s
2026-05-14 21:42:38 +02:00
fc3b4146a5 refactor(frontend): update API client to match cleaned REST routes 2026-05-14 21:34:26 +02:00
e64404cf40 refactor(routes): clean RESTful route table; content negotiation at /users/{username} 2026-05-14 21:31:02 +02:00
fbc02bc2f8 refactor(social): unified follow handler; remove federation handler module 2026-05-14 21:28:33 +02:00
d1f72c8308 refactor(users): content negotiation at GET /users/{username}; move lookup_handler; rename get_me_following 2026-05-14 21:25:49 +02:00
abc5f2b936 refactor(api): notification state changes use PATCH with JSON body 2026-05-14 21:05:30 +02:00
d5a116e483 feat(activitypub-base): impl actor_json port; return full user@domain handle from lookup 2026-05-14 20:47:41 +02:00
57110f3b75 feat(domain): add actor_json to FederationActionPort 2026-05-14 20:46:10 +02:00
812cf7b140 docs: REST API cleanup implementation plan 2026-05-14 20:44:31 +02:00
93967e53a2 docs: REST API cleanup design spec 2026-05-14 20:38:05 +02:00
16 changed files with 1832 additions and 235 deletions

View File

@@ -1382,9 +1382,12 @@ impl domain::ports::FederationActionPort for ActivityPubService {
domain::errors::DomainError::ExternalService(e.to_string())
})?;
let domain_str = actor.ap_id.host_str().unwrap_or("");
let full_handle = format!("{}@{}", actor.username, domain_str);
Ok(domain::models::remote_actor::RemoteActor {
url: actor.ap_id.to_string(),
handle: actor.username.clone(),
handle: full_handle,
display_name: Some(actor.username.clone()),
inbox_url: actor.inbox_url.to_string(),
shared_inbox_url: None,
@@ -1403,6 +1406,111 @@ impl domain::ports::FederationActionPort for ActivityPubService {
.await
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
}
async fn actor_json(
&self,
user_id: &domain::value_objects::UserId,
) -> Result<String, domain::errors::DomainError> {
ActivityPubService::actor_json(self, &user_id.as_uuid().to_string())
.await
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
}
async fn followers_collection_json(
&self,
user_id: &domain::value_objects::UserId,
page: Option<u32>,
) -> Result<String, domain::errors::DomainError> {
let data = self.federation_config.to_request_data();
let uuid = user_id.as_uuid();
let collection_id = format!("{}/users/{}/followers", self.base_url, uuid);
let total = data
.federation_repo
.count_followers(uuid)
.await
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?;
let obj = if let Some(p) = page {
let p = p.max(1);
let offset = (p.saturating_sub(1) as usize) * 20;
let followers = data
.federation_repo
.get_followers_page(uuid, offset as u32, 20)
.await
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?;
let has_next = offset + followers.len() < total;
let items: Vec<String> = followers.into_iter().map(|f| f.actor.url).collect();
let mut obj = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollectionPage",
"id": format!("{}?page={}", collection_id, p),
"partOf": collection_id,
"totalItems": total,
"orderedItems": items,
});
if has_next {
obj["next"] = serde_json::json!(format!("{}?page={}", collection_id, p + 1));
}
obj
} else {
serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollection",
"id": collection_id,
"totalItems": total,
"first": format!("{}?page=1", collection_id),
})
};
serde_json::to_string(&obj)
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
}
async fn following_collection_json(
&self,
user_id: &domain::value_objects::UserId,
page: Option<u32>,
) -> Result<String, domain::errors::DomainError> {
let data = self.federation_config.to_request_data();
let uuid = user_id.as_uuid();
let collection_id = format!("{}/users/{}/following", self.base_url, uuid);
let total = data
.federation_repo
.count_following(uuid)
.await
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?;
let obj = if let Some(p) = page {
let p = p.max(1);
let offset = (p.saturating_sub(1) as usize) * 20;
let following = data
.federation_repo
.get_following_page(uuid, offset as u32, 20)
.await
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?;
let has_next = offset + following.len() < total;
let items: Vec<String> = following.into_iter().map(|a| a.url).collect();
let mut obj = serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollectionPage",
"id": format!("{}?page={}", collection_id, p),
"partOf": collection_id,
"totalItems": total,
"orderedItems": items,
});
if has_next {
obj["next"] = serde_json::json!(format!("{}?page={}", collection_id, p + 1));
}
obj
} else {
serde_json::json!({
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollection",
"id": collection_id,
"totalItems": total,
"first": format!("{}?page=1", collection_id),
})
};
serde_json::to_string(&obj)
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
}
}
#[cfg(test)]

View File

@@ -83,6 +83,6 @@ pub struct SearchQuery {
#[derive(serde::Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct FollowRemoteRequest {
pub handle: String,
pub struct NotificationUpdateRequest {
pub read: bool,
}

View File

@@ -2,8 +2,6 @@ mod config;
mod factory;
use activitypub_base::{
actor_handler::actor_handler,
followers_handler::{followers_handler, following_handler},
inbox::inbox_handler,
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
outbox::outbox_handler,
@@ -50,7 +48,6 @@ async fn main() {
axum::routing::get(nodeinfo_well_known_handler),
)
.route("/nodeinfo/2.0", axum::routing::get(nodeinfo_handler))
.route("/users/{username}", axum::routing::get(actor_handler))
.route(
"/users/{username}/inbox",
axum::routing::post(inbox_handler),
@@ -59,14 +56,6 @@ async fn main() {
"/users/{username}/outbox",
axum::routing::get(outbox_handler),
)
.route(
"/users/{username}/followers",
axum::routing::get(followers_handler),
)
.route(
"/users/{username}/following",
axum::routing::get(following_handler),
)
.layer(infra.ap_service.federation_config().middleware());
let base = presentation::routes::router()

View File

@@ -198,6 +198,17 @@ pub trait RemoteActorRepository: Send + Sync {
pub trait FederationActionPort: Send + Sync {
async fn lookup_actor(&self, handle: &str) -> Result<RemoteActor, DomainError>;
async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
async fn actor_json(&self, user_id: &UserId) -> Result<String, DomainError>;
async fn followers_collection_json(
&self,
user_id: &UserId,
page: Option<u32>,
) -> Result<String, DomainError>;
async fn following_collection_json(
&self,
user_id: &UserId,
page: Option<u32>,
) -> Result<String, DomainError>;
}
#[async_trait]

View File

@@ -547,6 +547,26 @@ impl FederationActionPort for TestStore {
) -> Result<(), DomainError> {
Ok(())
}
async fn actor_json(&self, _user_id: &UserId) -> Result<String, DomainError> {
Err(DomainError::NotFound)
}
async fn followers_collection_json(
&self,
_user_id: &UserId,
_page: Option<u32>,
) -> Result<String, DomainError> {
Err(DomainError::NotFound)
}
async fn following_collection_json(
&self,
_user_id: &UserId,
_page: Option<u32>,
) -> Result<String, DomainError> {
Err(DomainError::NotFound)
}
}
#[async_trait]
@@ -806,6 +826,13 @@ mod federation_port_tests {
.await
.unwrap();
}
#[tokio::test]
async fn test_store_actor_json_returns_not_found() {
let store = TestStore::default();
let err = store.actor_json(&UserId::new()).await.unwrap_err();
assert!(matches!(err, DomainError::NotFound));
}
}
#[cfg(test)]

View File

@@ -1,130 +0,0 @@
use axum::{
extract::{Query, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
use api_types::{requests::FollowRemoteRequest, responses::RemoteActorResponse};
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
#[derive(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,
}))
}
pub async fn follow_remote_handler(
State(s): State<AppState>,
AuthUser(uid): AuthUser,
Json(body): Json<FollowRemoteRequest>,
) -> Result<StatusCode, ApiError> {
s.federation.follow_remote(&uid, &body.handle).await?;
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use axum::{
body::Body,
http::{Request, StatusCode},
routing::{get, post},
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("/federation/lookup", get(lookup_handler))
.route("/federation/follow", post(follow_remote_handler))
.with_state(make_state())
}
#[tokio::test]
async fn lookup_unknown_handle_returns_404() {
let req = Request::builder()
.uri("/federation/lookup?handle=%40alice%40example.com")
.body(Body::empty())
.unwrap();
let resp = app().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn follow_remote_without_auth_returns_401() {
let req = Request::builder()
.method("POST")
.uri("/federation/follow")
.header("content-type", "application/json")
.body(Body::from(r#"{"handle":"@alice@example.com"}"#))
.unwrap();
let resp = app().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
}

View File

@@ -14,9 +14,12 @@ use application::use_cases::profile::get_user_by_username;
use application::use_cases::search::{search_thoughts, search_users};
use axum::{
extract::{Path, Query, State},
http::{header, HeaderMap},
response::{IntoResponse, Response},
Json,
};
use domain::models::feed::PageParams;
use domain::value_objects::UserId;
fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
ThoughtResponse {
@@ -151,34 +154,82 @@ pub async fn search_handler(
pub async fn get_following_handler(
State(s): State<AppState>,
Path(username): Path<String>,
Path(param): Path<String>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
let user = get_user_by_username(&*s.users, &username).await?;
headers: HeaderMap,
) -> Result<Response, ApiError> {
let accept = headers
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if accept.contains("application/activity+json") {
let user_id = resolve_user_id(&s, &param).await?;
let page = q.page().try_into().ok();
let json = s
.federation
.following_collection_json(&user_id, page)
.await?;
return Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response());
}
let user = get_user_by_username(&*s.users, &param).await?;
let page = PageParams {
page: q.page(),
per_page: q.per_page(),
};
let result = get_following(&*s.follows, &user.id, page).await?;
Ok(Json(
serde_json::json!({ "total": result.total, "items": result.items.iter().map(to_user_response).collect::<Vec<_>>() }),
))
Ok(Json(serde_json::json!({
"total": result.total,
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>()
}))
.into_response())
}
pub async fn get_followers_handler(
State(s): State<AppState>,
Path(username): Path<String>,
Path(param): Path<String>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
let user = get_user_by_username(&*s.users, &username).await?;
headers: HeaderMap,
) -> Result<Response, ApiError> {
let accept = headers
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if accept.contains("application/activity+json") {
let user_id = resolve_user_id(&s, &param).await?;
let page = q.page().try_into().ok();
let json = s
.federation
.followers_collection_json(&user_id, page)
.await?;
return Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response());
}
let user = get_user_by_username(&*s.users, &param).await?;
let page = PageParams {
page: q.page(),
per_page: q.per_page(),
};
let result = get_followers(&*s.follows, &user.id, page).await?;
Ok(Json(
serde_json::json!({ "total": result.total, "items": result.items.iter().map(to_user_response).collect::<Vec<_>>() }),
))
Ok(Json(serde_json::json!({
"total": result.total,
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>()
}))
.into_response())
}
async fn resolve_user_id(s: &AppState, param: &str) -> Result<UserId, ApiError> {
if let Ok(uuid) = uuid::Uuid::parse_str(param) {
s.users
.find_by_id(&UserId::from_uuid(uuid))
.await?
.map(|u| u.id)
.ok_or_else(|| ApiError::from(domain::errors::DomainError::NotFound))
} else {
Ok(get_user_by_username(&*s.users, param).await?.id)
}
}
#[utoipa::path(

View File

@@ -1,6 +1,5 @@
pub mod api_keys;
pub mod auth;
pub mod federation;
pub mod feed;
pub mod health;
pub mod notifications;

View File

@@ -1,4 +1,5 @@
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
use api_types::requests::NotificationUpdateRequest;
use application::use_cases::notifications::{
list_notifications as uc_list_notifications, mark_all_notifications_read,
mark_notification_read as uc_mark_notification_read,
@@ -21,26 +22,136 @@ pub async fn list_notifications(
per_page: 20,
};
let result = uc_list_notifications(&*s.notifications, &uid, page).await?;
Ok(Json(
serde_json::json!({ "total": result.total, "unread": result.items.iter().filter(|n| !n.read).count() }),
))
Ok(Json(serde_json::json!({
"total": result.total,
"unread": result.items.iter().filter(|n| !n.read).count()
})))
}
#[utoipa::path(post, path = "/notifications/{id}/read", params(("id" = uuid::Uuid, Path, description = "Notification ID")), responses((status = 204, description = "Marked read")), security(("bearer_auth" = [])))]
#[utoipa::path(patch, path = "/notifications/{id}", params(("id" = uuid::Uuid, Path, description = "Notification ID")), request_body = NotificationUpdateRequest, responses((status = 204, description = "Marked read")), security(("bearer_auth" = [])))]
pub async fn mark_notification_read(
State(s): State<AppState>,
AuthUser(uid): AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<NotificationUpdateRequest>,
) -> Result<StatusCode, ApiError> {
uc_mark_notification_read(&*s.notifications, &NotificationId::from_uuid(id), &uid).await?;
if body.read {
uc_mark_notification_read(&*s.notifications, &NotificationId::from_uuid(id), &uid).await?;
}
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(post, path = "/notifications/read-all", responses((status = 204, description = "All marked read")), security(("bearer_auth" = [])))]
#[utoipa::path(patch, path = "/notifications", request_body = NotificationUpdateRequest, responses((status = 204, description = "All marked read")), security(("bearer_auth" = [])))]
pub async fn mark_all_read(
State(s): State<AppState>,
AuthUser(uid): AuthUser,
Json(body): Json<NotificationUpdateRequest>,
) -> Result<StatusCode, ApiError> {
mark_all_notifications_read(&*s.notifications, &uid).await?;
if body.read {
mark_all_notifications_read(&*s.notifications, &uid).await?;
}
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use axum::{
body::Body,
http::{header, Request},
routing::{get, patch},
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("/notifications", patch(mark_all_read))
.route("/notifications/:id", patch(mark_notification_read))
.with_state(make_state())
}
#[tokio::test]
async fn patch_notification_without_auth_returns_401() {
let resp = app()
.oneshot(
Request::builder()
.method("PATCH")
.uri("/notifications/00000000-0000-0000-0000-000000000001")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"read":true}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 401);
}
#[tokio::test]
async fn patch_all_without_auth_returns_401() {
let resp = app()
.oneshot(
Request::builder()
.method("PATCH")
.uri("/notifications")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"read":true}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 401);
}
}

View File

@@ -46,27 +46,46 @@ pub async fn delete_boost(
unboost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(post, path = "/users/{id}/follow", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Following")), security(("bearer_auth" = [])))]
#[utoipa::path(
post, path = "/users/{username}/follow",
params(("username" = String, Path, description = "Username or user@domain handle")),
responses((status = 204, description = "Following")),
security(("bearer_auth" = []))
)]
pub async fn post_follow(
State(s): State<AppState>,
AuthUser(uid): AuthUser,
Path(username): Path<String>,
) -> Result<StatusCode, ApiError> {
let target = get_user_by_username(&*s.users, &username).await?;
follow_user(&*s.follows, &*s.events, &uid, &target.id).await?;
if username.contains('@') {
s.federation.follow_remote(&uid, &username).await?;
} else {
let target = get_user_by_username(&*s.users, &username).await?;
follow_user(&*s.follows, &*s.events, &uid, &target.id).await?;
}
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(delete, path = "/users/{id}/follow", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Unfollowed")), security(("bearer_auth" = [])))]
#[utoipa::path(
delete, path = "/users/{username}/follow",
params(("username" = String, Path, description = "Username")),
responses((status = 204, description = "Unfollowed")),
security(("bearer_auth" = []))
)]
pub async fn delete_follow(
State(s): State<AppState>,
AuthUser(uid): AuthUser,
Path(username): Path<String>,
) -> Result<StatusCode, ApiError> {
if username.contains('@') {
return Err(ApiError::BadRequest(
"remote unfollow not yet supported".into(),
));
}
let target = get_user_by_username(&*s.users, &username).await?;
unfollow_user(&*s.follows, &*s.events, &uid, &target.id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(post, path = "/users/{id}/block", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Blocked")), security(("bearer_auth" = [])))]
#[utoipa::path(post, path = "/users/{username}/block", params(("username" = String, Path, description = "Username")), responses((status = 204, description = "Blocked")), security(("bearer_auth" = [])))]
pub async fn post_block(
State(s): State<AppState>,
AuthUser(uid): AuthUser,
@@ -76,7 +95,7 @@ pub async fn post_block(
block_user(&*s.blocks, &*s.events, &uid, &target.id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(delete, path = "/users/{id}/block", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Unblocked")), security(("bearer_auth" = [])))]
#[utoipa::path(delete, path = "/users/{username}/block", params(("username" = String, Path, description = "Username")), responses((status = 204, description = "Unblocked")), security(("bearer_auth" = [])))]
pub async fn delete_block(
State(s): State<AppState>,
AuthUser(uid): AuthUser,
@@ -106,3 +125,106 @@ pub async fn get_top_friends_handler(
let usernames: Vec<&str> = friends.iter().map(|(_, u)| u.username.as_str()).collect();
Ok(Json(serde_json::json!({ "topFriends": usernames })))
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use axum::{
body::Body,
http::Request,
routing::{delete, post},
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}/follow",
post(post_follow).delete(delete_follow),
)
.with_state(make_state())
}
#[tokio::test]
async fn follow_without_auth_returns_401() {
let resp = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/users/alice/follow")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 401);
}
#[tokio::test]
async fn unfollow_remote_without_auth_returns_401() {
let resp = app()
.oneshot(
Request::builder()
.method("DELETE")
.uri("/users/alice@example.com/follow")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 401);
}
}

View File

@@ -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<AppState>,
Path(username): Path<String>,
OptionalAuthUser(viewer): OptionalAuthUser,
) -> Result<Json<UserResponse>, ApiError> {
headers: HeaderMap,
) -> Result<Response, ApiError> {
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<AppState>,
AuthUser(uid): AuthUser,
Query(q): Query<PaginationQuery>,
@@ -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<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

@@ -1,6 +1,6 @@
use crate::{handlers::*, openapi, state::AppState};
use axum::{
routing::{delete, get, post, put},
routing::{delete, get, patch, post, put},
Router,
};
@@ -11,36 +11,38 @@ pub fn router() -> Router<AppState> {
// auth
.route("/auth/register", post(auth::post_register))
.route("/auth/login", post(auth::post_login))
// users — static paths before parameterised
// users — static before parameterised
.route("/users", get(users::get_users))
.route("/users/count", get(users::get_user_count))
.route(
"/users/me",
get(users::get_me)
.patch(users::patch_profile)
.put(users::patch_profile),
)
.route(
"/users/me/following-list",
get(users::get_me_following_list),
)
.route("/users/lookup", get(users::lookup_handler))
.route("/users/me", get(users::get_me).patch(users::patch_profile))
.route("/users/me/following", 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.
.route("/users/{username}/profile", get(users::get_user))
.route("/users/{username}", get(users::get_user))
.route(
"/users/{username}/top-friends",
get(social::get_top_friends_handler),
)
// follows & blocks (use {id} param)
.route(
"/users/{id}/follow",
"/users/{username}/follow",
post(social::post_follow).delete(social::delete_follow),
)
.route(
"/users/{id}/block",
"/users/{username}/block",
post(social::post_block).delete(social::delete_block),
)
.route(
"/users/{username}/followers",
get(feed::get_followers_handler),
)
.route(
"/users/{username}/following",
get(feed::get_following_handler),
)
.route(
"/users/{username}/thoughts",
get(feed::user_thoughts_handler),
)
// thoughts
.route("/thoughts", post(thoughts::post_thought))
.route(
@@ -63,42 +65,23 @@ pub fn router() -> Router<AppState> {
.route("/feed", get(feed::home_feed))
.route("/feed/public", get(feed::public_feed))
.route("/search", get(feed::search_handler))
.route(
"/users/{username}/follower-list",
get(feed::get_followers_handler),
)
.route(
"/users/{username}/following-list",
get(feed::get_following_handler),
)
.route(
"/users/{username}/thoughts",
get(feed::user_thoughts_handler),
)
.route("/tags/popular", get(feed::get_popular_tags))
.route("/tags/{name}", get(feed::tag_thoughts_handler))
// notifications
.route("/notifications", get(notifications::list_notifications))
.route(
"/notifications/read-all",
post(notifications::mark_all_read),
"/notifications",
get(notifications::list_notifications).patch(notifications::mark_all_read),
)
.route(
"/notifications/{id}/read",
post(notifications::mark_notification_read),
"/notifications/{id}",
patch(notifications::mark_notification_read),
)
// api keys
.route(
"/api-keys",
get(api_keys::get_api_keys).post(api_keys::post_api_key),
)
.route("/api-keys/{id}", delete(api_keys::delete_api_key_handler))
// federation
.route("/federation/lookup", get(federation::lookup_handler))
.route(
"/federation/follow",
post(federation::follow_remote_handler),
);
.route("/api-keys/{id}", delete(api_keys::delete_api_key_handler));
openapi::serve(api_routes)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
# REST API Cleanup Design
Clean up the REST API to be professional, consistent, and RESTful. No new features — only renames, unifications, and content negotiation.
## Route Changes
| Before | After | Reason |
|--------|-------|--------|
| `GET /users/{username}/profile` | `GET /users/{username}` | content negotiation replaces the /profile workaround |
| `GET /federation/lookup?handle=` | `GET /users/lookup?handle=` | federation lookup belongs under /users |
| `POST /users/{id}/follow` | `POST /users/{username}/follow` | param was mislabelled; now also handles remote follows |
| `DELETE /users/{id}/follow` | `DELETE /users/{username}/follow` | param rename |
| `POST /users/{id}/block` | `POST /users/{username}/block` | param rename |
| `DELETE /users/{id}/block` | `DELETE /users/{username}/block` | param rename |
| `GET /users/{username}/follower-list` | `GET /users/{username}/followers` | verbose name |
| `GET /users/{username}/following-list` | `GET /users/{username}/following` | verbose name |
| `GET /users/me/following-list` | `GET /users/me/following` | verbose name |
| `POST /notifications/{id}/read` | `PATCH /notifications/{id}` | POST for state change → PATCH |
| `POST /notifications/read-all` | `PATCH /notifications` | POST bulk action → PATCH |
| `PUT /users/me` | removed | `PATCH /users/me` is sufficient |
| `POST /federation/follow` | removed | unified into `POST /users/{username}/follow` |
## Content Negotiation at `GET /users/{username}`
The AP router currently owns `/users/{username}` (returns `application/activity+json`). The REST profile was at `/users/{username}/profile` as a workaround.
**Solution:** Remove `/users/{username}` from the AP router. Add a single handler at `GET /users/{username}` in the REST router that checks the `Accept` header:
- `Accept: application/activity+json` → return AP actor JSON with `Content-Type: application/activity+json`
- Anything else → return `UserResponse` with `Content-Type: application/json`
**Implementation:**
Add `actor_json(&self, user_id: &UserId) -> Result<String, DomainError>` to `FederationActionPort` in domain. Implement in `ActivityPubService` by delegating to the existing `self.actor_json(&user_id.as_uuid().to_string())` inherent method.
The unified handler in `presentation/src/handlers/users.rs`:
1. Looks up user by username via `UserRepository` → 404 if not found
2. Checks `Accept` header
3. AP path: calls `s.federation.actor_json(&user.id)` → returns with `Content-Type: application/activity+json`
4. REST path: returns `UserResponse` as before
The AP router in `bootstrap/src/main.rs` no longer registers `/users/{username}`.
## Unified Follow at `POST /users/{username}/follow`
The handler detects whether `{username}` is a local user or a remote actor:
```rust
if username.contains('@') {
// Remote: e.g. "gabrielkaszewski@mastodon.social"
s.federation.follow_remote(&uid, &username).await?;
} else {
// Local: look up by username, call follow_user use case
let target = get_user_by_username(&*s.users, &username).await?;
follow_user(&*s.follows, &*s.events, &uid, &target.id).await?;
}
```
`POST /federation/follow` and `federation::follow_remote_handler` are deleted.
## Remote Actor Handle Format Fix
`lookup_actor` currently returns `handle: actor.username` (just `preferred_username`, e.g. `gabrielkaszewski`). Fix: return the full `user@domain` handle by extracting the domain from `actor.ap_id`:
```rust
let domain = actor.ap_id.host_str().unwrap_or("");
let full_handle = format!("{}@{}", actor.username, domain);
// RemoteActor { handle: full_handle, ... }
```
This means `RemoteActorResponse.handle` = `"gabrielkaszewski@mastodon.social"`, which the frontend passes directly to `POST /users/gabrielkaszewski@mastodon.social/follow`.
## Remote Unfollow Scope
`DELETE /users/{username}/follow` for a remote handle (contains `@`) is **out of scope**. The handler returns `501 Not Implemented` when `username` contains `@`. Remote unfollow requires an `Undo Follow` ActivityPub activity and is a separate feature.
## Notification Endpoints
Add `NotificationUpdateRequest { read: bool }` to `api-types/src/requests.rs`.
- `PATCH /notifications/{id}` — mark single notification read (body: `{"read": true}`)
- `PATCH /notifications` — mark all notifications read (body: `{"read": true}`)
Both replace their existing `POST` counterparts.
## Frontend (`thoughts-frontend/lib/api.ts`)
| Function | Change |
|----------|--------|
| `getUserProfile(username)` | URL: `/users/${username}/profile``/users/${username}` |
| `getFollowersList(username)` | URL: `/follower-list``/followers` |
| `getFollowingList(username)` | URL: `/following-list``/following` |
| `getMeFollowingList()` | URL: `/me/following-list``/me/following` |
| `lookupRemoteActor(handle)` | URL: `/federation/lookup?handle=``/users/lookup?handle=` |
| `followRemoteUser(handle)` | **Deleted** — use unified `followUser(handle)` instead |
| `markNotificationRead(id)` | **New**`PATCH /notifications/{id}` with body `{"read":true}` (no prior frontend impl) |
| `markAllNotificationsRead()` | **New**`PATCH /notifications` with body `{"read":true}` (no prior frontend impl) |
Also update `remote-user-card.tsx` to call `followUser(actor.handle, token)` instead of `followRemoteUser`.
## Files Touched
**Backend:**
- `crates/domain/src/ports.rs` — add `actor_json` to `FederationActionPort`
- `crates/domain/src/testing.rs` — add `actor_json` to `TestStore` impl
- `crates/adapters/activitypub-base/src/service.rs` — add `actor_json` to `FederationActionPort` impl; fix `lookup_actor` handle format
- `crates/presentation/src/handlers/users.rs` — unified `GET /users/{username}` handler; remove old `get_user` (was /profile)
- `crates/presentation/src/handlers/social.rs` — unify `post_follow`; rename `{id}``{username}` in follow/block; rename follower/following list handlers
- `crates/presentation/src/handlers/federation.rs` — delete `follow_remote_handler`; move `lookup_handler` to `users.rs`; delete file if empty
- `crates/presentation/src/handlers/notifications.rs` — replace read handlers with PATCH
- `crates/presentation/src/routes.rs` — all route changes
- `crates/api-types/src/requests.rs` — add `NotificationUpdateRequest`
- `crates/bootstrap/src/main.rs` — remove `/users/{username}` from ap_router
**Frontend:**
- `thoughts-frontend/lib/api.ts` — all URL/method changes listed above
- `thoughts-frontend/components/remote-user-card.tsx` — use `followUser` instead of `followRemoteUser`
- Any page that calls `getFollowersList`, `getFollowingList`, `getMeFollowingList`, `markNotificationRead`, `markAllNotificationsRead` (check all pages under `app/`)

View File

@@ -2,7 +2,7 @@
import { useState } from "react";
import { useAuth } from "@/hooks/use-auth";
import { followRemoteUser, RemoteActor } from "@/lib/api";
import { followUser, RemoteActor } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { UserAvatar } from "@/components/user-avatar";
import { toast } from "sonner";
@@ -24,7 +24,7 @@ export function RemoteUserCard({ actor }: RemoteUserCardProps) {
}
setLoading(true);
try {
await followRemoteUser(actor.handle, token);
await followUser(actor.handle, token);
setFollowed(true);
toast.success(`Follow request sent to ${actor.handle}`);
} catch {

View File

@@ -194,18 +194,18 @@ export const updateProfile = (data: z.infer<typeof UpdateProfileSchema>, token:
apiFetch("/users/me", { method: "PATCH", body: JSON.stringify(data) }, UserSchema, token);
export const getMeFollowingList = (token: string) =>
apiFetch("/users/me/following-list", {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
apiFetch("/users/me/following", {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
// ── Users ─────────────────────────────────────────────────────────────────
export const getUserProfile = (username: string, token: string | null) =>
apiFetch(`/users/${username}/profile`, {}, UserSchema, token);
apiFetch(`/users/${username}`, {}, UserSchema, token);
export const getFollowersList = (username: string, token: string | null) =>
apiFetch(`/users/${username}/follower-list`, {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
apiFetch(`/users/${username}/followers`, {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
export const getFollowingList = (username: string, token: string | null) =>
apiFetch(`/users/${username}/following-list`, {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
apiFetch(`/users/${username}/following`, {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
export const getTopFriends = (username: string, token: string | null) =>
apiFetch(`/users/${username}/top-friends`, {}, z.object({ topFriends: z.array(z.string()) }), token);
@@ -216,22 +216,30 @@ export const followUser = (username: string, token: string) =>
export const unfollowUser = (username: string, token: string) =>
apiFetch(`/users/${username}/follow`, { method: "DELETE" }, z.null(), token);
export const lookupRemoteActor = (handle: string, token: string | null) =>
export const markNotificationRead = (id: string, token: string) =>
apiFetch(
`/federation/lookup?handle=${encodeURIComponent(handle)}`,
{},
RemoteActorSchema,
`/notifications/${id}`,
{ method: "PATCH", body: JSON.stringify({ read: true }) },
z.null(),
token
);
export const followRemoteUser = (handle: string, token: string) =>
export const markAllNotificationsRead = (token: string) =>
apiFetch(
`/federation/follow`,
{ method: "POST", body: JSON.stringify({ handle }) },
"/notifications",
{ method: "PATCH", body: JSON.stringify({ read: true }) },
z.null(),
token
);
export const lookupRemoteActor = (handle: string, token: string | null) =>
apiFetch(
`/users/lookup?handle=${encodeURIComponent(handle)}`,
{},
RemoteActorSchema,
token
);
export const getAllUsers = (page: number = 1, pageSize: number = 20) =>
apiFetch(
`/users?page=${page}&per_page=${pageSize}`,