feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready (#1)
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled

This commit was merged in pull request #1.
This commit is contained in:
2026-05-16 09:42:40 +00:00
parent 071809bc3f
commit 9aee4ceb6d
224 changed files with 35418 additions and 1469 deletions

View File

@@ -0,0 +1,58 @@
use crate::{
deps_struct,
errors::ApiError,
extractors::{AuthUser, Deps},
};
use api_types::{
requests::CreateApiKeyRequest,
responses::{ApiKeyResponse, CreatedApiKeyResponse},
};
use application::use_cases::api_keys::{create_api_key, delete_api_key, list_api_keys};
use axum::{
extract::Path,
http::StatusCode,
Json,
};
use domain::{ports::ApiKeyRepository, value_objects::ApiKeyId};
use uuid::Uuid;
deps_struct!(ApiKeysDeps {
api_keys: ApiKeyRepository,
});
#[utoipa::path(get, path = "/api-keys", responses((status = 200, description = "API keys", body = Vec<ApiKeyResponse>)), security(("bearer_auth" = [])))]
pub async fn get_api_keys(
Deps(d): Deps<ApiKeysDeps>,
AuthUser(uid): AuthUser,
) -> Result<Json<Vec<ApiKeyResponse>>, ApiError> {
let keys = list_api_keys(&*d.api_keys, &uid).await?;
Ok(Json(
keys.into_iter()
.map(|k| ApiKeyResponse {
id: k.id.as_uuid(),
name: k.name,
created_at: k.created_at,
})
.collect(),
))
}
#[utoipa::path(post, path = "/api-keys", request_body = CreateApiKeyRequest, responses((status = 200, description = "Created — raw key shown once", body = CreatedApiKeyResponse)), security(("bearer_auth" = [])))]
pub async fn post_api_key(
Deps(d): Deps<ApiKeysDeps>,
AuthUser(uid): AuthUser,
Json(body): Json<CreateApiKeyRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let (key, raw) = create_api_key(&*d.api_keys, &uid, body.name).await?;
Ok(Json(
serde_json::json!({ "id": key.id.as_uuid(), "name": key.name, "key": raw }),
))
}
#[utoipa::path(delete, path = "/api-keys/{id}", params(("id" = uuid::Uuid, Path, description = "Key ID")), responses((status = 204, description = "Deleted")), security(("bearer_auth" = [])))]
pub async fn delete_api_key_handler(
Deps(d): Deps<ApiKeysDeps>,
AuthUser(uid): AuthUser,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
delete_api_key(&*d.api_keys, &uid, &ApiKeyId::from_uuid(id)).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -0,0 +1,94 @@
use crate::{
deps_struct,
errors::ApiError,
extractors::Deps,
};
use api_types::{
requests::{LoginRequest, RegisterRequest},
responses::{AuthResponse, ErrorResponse, UserResponse},
};
use application::use_cases::auth::{login, register, LoginInput, RegisterInput};
use axum::{http::StatusCode, response::IntoResponse, Json};
use domain::ports::{AuthService, EventPublisher, PasswordHasher, UserRepository};
deps_struct!(AuthDeps {
users: UserRepository,
hasher: PasswordHasher,
auth: AuthService,
events: EventPublisher,
});
pub fn to_user_response(u: &domain::models::user::User) -> UserResponse {
UserResponse {
id: u.id.as_uuid(),
username: u.username.to_string(),
display_name: u.display_name.clone(),
bio: u.bio.clone(),
avatar_url: u.avatar_url.clone(),
header_url: u.header_url.clone(),
custom_css: u.custom_css.clone(),
local: u.local,
is_followed_by_viewer: false,
created_at: u.created_at,
}
}
#[utoipa::path(
post, path = "/auth/register",
request_body = RegisterRequest,
responses(
(status = 201, description = "User registered", body = AuthResponse),
(status = 409, description = "Username or email taken", body = ErrorResponse),
(status = 422, description = "Invalid input", body = ErrorResponse),
)
)]
pub async fn post_register(
Deps(d): Deps<AuthDeps>,
Json(body): Json<RegisterRequest>,
) -> Result<impl IntoResponse, ApiError> {
let out = register(
&*d.users,
&*d.hasher,
&*d.auth,
&*d.events,
RegisterInput {
username: body.username,
email: body.email,
password: body.password,
},
)
.await?;
let resp = AuthResponse {
token: out.token,
user: to_user_response(&out.user),
};
Ok((StatusCode::CREATED, Json(resp)))
}
#[utoipa::path(
post, path = "/auth/login",
request_body = LoginRequest,
responses(
(status = 200, description = "Login successful", body = AuthResponse),
(status = 401, description = "Invalid credentials", body = ErrorResponse),
)
)]
pub async fn post_login(
Deps(d): Deps<AuthDeps>,
Json(body): Json<LoginRequest>,
) -> Result<impl IntoResponse, ApiError> {
let out = login(
&*d.users,
&*d.hasher,
&*d.auth,
LoginInput {
email: body.email,
password: body.password,
},
)
.await?;
Ok(Json(AuthResponse {
token: out.token,
user: to_user_response(&out.user),
}))
}

View File

@@ -0,0 +1,151 @@
use crate::{
errors::ApiError,
extractors::{Deps, FromAppState, OptionalAuthUser},
handlers::feed::to_thought_response,
state::AppState,
};
use api_types::{
requests::PaginationQuery,
responses::{ActorConnectionPageResponse, ActorConnectionResponse},
};
use application::use_cases::federation_management::{
get_actor_connections_page, get_remote_actor_posts,
};
use axum::{
extract::{Path, Query},
Json,
};
use activitypub_base::ActivityPubRepository;
use domain::{
models::feed::PageParams,
ports::{
FederationActionPort, FederationSchedulerPort, FeedRepository,
RemoteActorConnectionRepository,
},
};
use std::sync::Arc;
pub struct FederationActorsDeps {
pub federation: Arc<dyn FederationActionPort>,
pub ap_repo: Arc<dyn ActivityPubRepository>,
pub feed: Arc<dyn FeedRepository>,
pub federation_scheduler: Arc<dyn FederationSchedulerPort>,
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
}
impl FromAppState for FederationActorsDeps {
fn from_state(s: &AppState) -> Self {
Self {
federation: s.federation.clone(),
ap_repo: s.ap_repo.clone(),
feed: s.feed.clone(),
federation_scheduler: s.federation_scheduler.clone(),
remote_actor_connections: s.remote_actor_connections.clone(),
}
}
}
pub async fn remote_actor_posts_handler(
Deps(d): Deps<FederationActorsDeps>,
Path(handle): Path<String>,
Query(q): Query<PaginationQuery>,
OptionalAuthUser(viewer): OptionalAuthUser,
) -> Result<Json<serde_json::Value>, ApiError> {
let page = PageParams {
page: q.page(),
per_page: q.per_page(),
};
let result = get_remote_actor_posts(
&*d.federation,
&*d.ap_repo,
&*d.feed,
&*d.federation_scheduler,
&handle,
page,
viewer.as_ref(),
)
.await?;
Ok(Json(serde_json::json!({
"total": result.total,
"page": result.page,
"per_page": result.per_page,
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
})))
}
pub async fn actor_followers_handler(
Deps(d): Deps<FederationActorsDeps>,
Path(handle): Path<String>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
actor_connections_handler(d, handle, "followers", q.page() as u32).await
}
pub async fn actor_following_handler(
Deps(d): Deps<FederationActorsDeps>,
Path(handle): Path<String>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
actor_connections_handler(d, handle, "following", q.page() as u32).await
}
async fn actor_connections_handler(
d: FederationActorsDeps,
handle: String,
connection_type: &str,
page: u32,
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
let (items, has_more) = get_actor_connections_page(
&*d.federation,
&*d.remote_actor_connections,
&*d.federation_scheduler,
&handle,
connection_type,
page,
)
.await?;
Ok(Json(ActorConnectionPageResponse {
items: items
.into_iter()
.map(|a| ActorConnectionResponse {
handle: a.handle,
display_name: a.display_name,
avatar_url: a.avatar_url,
url: a.url,
})
.collect(),
page,
has_more,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::make_state;
use axum::{body::Body, http::Request, routing::get, Router};
use tower::ServiceExt;
fn app() -> Router {
Router::new()
.route(
"/federation/actors/{handle}/posts",
get(remote_actor_posts_handler),
)
.with_state(make_state())
}
#[tokio::test]
async fn unknown_actor_returns_404() {
let resp = app()
.oneshot(
Request::builder()
.uri("/federation/actors/%40alice%40example.com/posts")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 404);
}
}

View File

@@ -0,0 +1,109 @@
use crate::{
deps_struct,
errors::ApiError,
extractors::{AuthUser, Deps},
};
use api_types::responses::{ProfileField, RemoteActorResponse};
use application::use_cases::federation_management::{
accept_follow_request, list_pending_requests, list_remote_followers, list_remote_following,
reject_follow_request, remove_remote_following,
};
use axum::{http::StatusCode, Json};
use domain::ports::{EventPublisher, FederationActionPort, FollowRepository, UserRepository};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct ActorUrlBody {
pub actor_url: String,
}
#[derive(Deserialize)]
pub struct HandleBody {
pub handle: String,
}
deps_struct!(FederationManagementDeps {
federation: FederationActionPort,
follows: FollowRepository,
users: UserRepository,
events: EventPublisher,
});
fn to_response(a: domain::models::remote_actor::RemoteActor) -> RemoteActorResponse {
RemoteActorResponse {
handle: a.handle,
display_name: a.display_name,
avatar_url: a.avatar_url,
url: a.url,
bio: a.bio,
banner_url: a.banner_url,
also_known_as: a.also_known_as,
outbox_url: a.outbox_url,
followers_url: a.followers_url,
following_url: a.following_url,
attachment: a
.attachment
.into_iter()
.map(|(name, value)| ProfileField { name, value })
.collect(),
}
}
pub async fn get_pending_requests(
Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser,
) -> Result<Json<Vec<RemoteActorResponse>>, ApiError> {
let actors = list_pending_requests(&*d.federation, &uid).await?;
Ok(Json(actors.into_iter().map(to_response).collect()))
}
pub async fn post_accept_request(
Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser,
Json(body): Json<ActorUrlBody>,
) -> Result<StatusCode, ApiError> {
accept_follow_request(&*d.federation, &uid, &body.actor_url).await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn delete_follower(
Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser,
Json(body): Json<ActorUrlBody>,
) -> Result<StatusCode, ApiError> {
reject_follow_request(&*d.federation, &uid, &body.actor_url).await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn get_remote_followers(
Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser,
) -> Result<Json<Vec<RemoteActorResponse>>, ApiError> {
let actors = list_remote_followers(&*d.federation, &uid).await?;
Ok(Json(actors.into_iter().map(to_response).collect()))
}
pub async fn get_remote_following(
Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser,
) -> Result<Json<Vec<RemoteActorResponse>>, ApiError> {
let actors = list_remote_following(&*d.federation, &uid).await?;
Ok(Json(actors.into_iter().map(to_response).collect()))
}
pub async fn delete_following(
Deps(d): Deps<FederationManagementDeps>,
AuthUser(uid): AuthUser,
Json(body): Json<HandleBody>,
) -> Result<StatusCode, ApiError> {
remove_remote_following(
&*d.follows,
&*d.users,
&*d.federation,
&*d.events,
&uid,
&body.handle,
)
.await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -0,0 +1,279 @@
use crate::{
deps_struct,
errors::ApiError,
extractors::{AuthUser, Deps, OptionalAuthUser},
handlers::auth::to_user_response,
};
use api_types::requests::{PaginationQuery, SearchQuery};
use api_types::responses::ThoughtResponse;
use application::use_cases::feed::get_home_feed;
use application::use_cases::profile::{get_user_by_id_or_username, get_user_by_username};
use axum::{
extract::{Path, Query},
http::{header, HeaderMap},
response::{IntoResponse, Response},
Json,
};
use domain::{
models::feed::PageParams,
ports::{FederationActionPort, FeedQuery, FeedRepository, FollowRepository, SearchPort, TagRepository, UserRepository},
};
deps_struct!(FeedDeps {
feed: FeedRepository,
follows: FollowRepository,
search: SearchPort,
federation: FederationActionPort,
users: UserRepository,
tags: TagRepository,
});
pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
ThoughtResponse {
id: e.thought.id.as_uuid(),
content: e.thought.content.as_str().to_string(),
author: to_user_response(&e.author),
in_reply_to_id: e.thought.in_reply_to_id.as_ref().map(|id| id.as_uuid()),
in_reply_to_url: None,
visibility: e.thought.visibility.as_str().to_string(),
content_warning: e.thought.content_warning.clone(),
sensitive: e.thought.sensitive,
like_count: e.stats.like_count,
boost_count: e.stats.boost_count,
reply_count: e.stats.reply_count,
liked_by_viewer: e.viewer.as_ref().map(|v| v.liked).unwrap_or(false),
boosted_by_viewer: e.viewer.as_ref().map(|v| v.boosted).unwrap_or(false),
created_at: e.thought.created_at,
updated_at: e.thought.updated_at,
}
}
#[utoipa::path(
get, path = "/feed",
params(PaginationQuery),
responses((status = 200, description = "Home feed")),
security(("bearer_auth" = []))
)]
pub async fn home_feed(
Deps(d): Deps<FeedDeps>,
AuthUser(uid): AuthUser,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
let page = PageParams {
page: q.page(),
per_page: q.per_page(),
};
let result = get_home_feed(&*d.feed, &*d.follows, &uid, page).await?;
Ok(Json(serde_json::json!({
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
"total": result.total,
"page": result.page,
"per_page": result.per_page,
})))
}
#[utoipa::path(
get, path = "/feed/public",
params(PaginationQuery),
responses((status = 200, description = "Public feed"))
)]
pub async fn public_feed(
Deps(d): Deps<FeedDeps>,
OptionalAuthUser(viewer): OptionalAuthUser,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
let page = PageParams {
page: q.page(),
per_page: q.per_page(),
};
let result = d.feed.query(&FeedQuery::public(page, viewer)).await?;
Ok(Json(serde_json::json!({
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
"total": result.total,
"page": result.page,
"per_page": result.per_page,
})))
}
#[utoipa::path(
get, path = "/search",
params(SearchQuery),
responses((status = 200, description = "Search results: thoughts and users"))
)]
pub async fn search_handler(
Deps(d): Deps<FeedDeps>,
OptionalAuthUser(viewer): OptionalAuthUser,
Query(q): Query<SearchQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
let page = PageParams {
page: q.page.unwrap_or(api_types::requests::DEFAULT_PAGE),
per_page: q.per_page.unwrap_or(api_types::requests::DEFAULT_PER_PAGE),
};
let query = q.q.trim().to_string();
let (thoughts_result, users_result) = tokio::join!(
d.search.search_thoughts(&query, &page, viewer.as_ref()),
d.search.search_users(&query, &page),
);
let thoughts = thoughts_result?
.items
.iter()
.map(to_thought_response)
.collect::<Vec<_>>();
let users = users_result?
.items
.into_iter()
.map(|u| to_user_response(&u))
.collect::<Vec<_>>();
Ok(Json(serde_json::json!({
"query": query,
"thoughts": thoughts,
"users": users,
})))
}
pub async fn get_following_handler(
Deps(d): Deps<FeedDeps>,
Path(param): Path<String>,
Query(q): Query<PaginationQuery>,
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 = get_user_by_id_or_username(&*d.users, &param).await?;
let user_id = user.id;
let page = q.page().try_into().ok();
let json = d
.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(&*d.users, &param).await?;
let page = PageParams {
page: q.page(),
per_page: q.per_page(),
};
let result = d.follows.list_following(&user.id, &page).await?;
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(
Deps(d): Deps<FeedDeps>,
Path(param): Path<String>,
Query(q): Query<PaginationQuery>,
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 = get_user_by_id_or_username(&*d.users, &param).await?;
let user_id = user.id;
let page = q.page().try_into().ok();
let json = d
.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(&*d.users, &param).await?;
let page = PageParams {
page: q.page(),
per_page: q.per_page(),
};
let result = d.follows.list_followers(&user.id, &page).await?;
Ok(Json(serde_json::json!({
"total": result.total,
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>()
}))
.into_response())
}
#[utoipa::path(
get, path = "/users/{username}/thoughts",
params(
("username" = String, Path, description = "Username"),
PaginationQuery,
),
responses((status = 200, description = "User's public thoughts"))
)]
pub async fn user_thoughts_handler(
Deps(d): Deps<FeedDeps>,
Path(username): Path<String>,
OptionalAuthUser(viewer): OptionalAuthUser,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
let user = get_user_by_username(&*d.users, &username).await?;
let page = PageParams {
page: q.page(),
per_page: q.per_page(),
};
let result = d.feed.query(&FeedQuery::user(user.id.clone(), page, viewer)).await?;
Ok(Json(serde_json::json!({
"total": result.total,
"page": result.page,
"per_page": result.per_page,
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>()
})))
}
pub async fn get_popular_tags(
Deps(d): Deps<FeedDeps>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, ApiError> {
let limit: usize = params
.get("limit")
.and_then(|v| v.parse().ok())
.unwrap_or(api_types::requests::DEFAULT_PER_PAGE as usize);
let tags = d.tags.popular_tags(limit.min(api_types::requests::MAX_PER_PAGE as usize)).await?;
Ok(Json(serde_json::json!({
"tags": tags.iter().map(|(name, count)| serde_json::json!({
"name": name,
"thought_count": count,
})).collect::<Vec<_>>()
})))
}
#[utoipa::path(
get, path = "/tags/{name}",
params(
("name" = String, Path, description = "Tag name"),
PaginationQuery,
),
responses((status = 200, description = "Thoughts with this tag"))
)]
pub async fn tag_thoughts_handler(
Deps(d): Deps<FeedDeps>,
Path(tag_name): Path<String>,
OptionalAuthUser(viewer): OptionalAuthUser,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
let page = PageParams {
page: q.page(),
per_page: q.per_page(),
};
let result = d.feed.query(&FeedQuery::tag(&tag_name, page, viewer)).await?;
Ok(Json(serde_json::json!({
"tag": tag_name,
"total": result.total,
"page": result.page,
"per_page": result.per_page,
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
})))
}

View File

@@ -0,0 +1,28 @@
use crate::{
extractors::{Deps, FromAppState},
state::AppState,
};
use axum::Json;
use domain::ports::UserRepository;
use std::sync::Arc;
pub struct HealthDeps {
pub users: Arc<dyn UserRepository>,
}
impl FromAppState for HealthDeps {
fn from_state(s: &AppState) -> Self {
Self {
users: s.users.clone(),
}
}
}
#[utoipa::path(get, path = "/health", responses((status = 200, description = "Service health status")))]
pub async fn health_handler(Deps(d): Deps<HealthDeps>) -> Json<serde_json::Value> {
let db_ok = d.users.list_with_stats().await.is_ok();
Json(serde_json::json!({
"status": if db_ok { "ok" } else { "degraded" },
"db": if db_ok { "connected" } else { "error" },
}))
}

View File

@@ -0,0 +1,10 @@
pub mod api_keys;
pub mod auth;
pub mod federation_actors;
pub mod federation_management;
pub mod feed;
pub mod health;
pub mod notifications;
pub mod social;
pub mod thoughts;
pub mod users;

View File

@@ -0,0 +1,119 @@
use crate::{
deps_struct,
errors::ApiError,
extractors::{AuthUser, Deps},
};
use api_types::requests::NotificationUpdateRequest;
use application::use_cases::notifications::{
count_unread_notifications, list_notifications as uc_list_notifications,
mark_all_notifications_read, mark_notification_read as uc_mark_notification_read,
};
use axum::{
extract::Path,
http::StatusCode,
Json,
};
use domain::{
models::feed::PageParams, ports::NotificationRepository, value_objects::NotificationId,
};
use uuid::Uuid;
deps_struct!(NotificationsDeps {
notifications: NotificationRepository,
});
#[utoipa::path(get, path = "/notifications", responses((status = 200, description = "Notification summary")), security(("bearer_auth" = [])))]
pub async fn list_notifications(
Deps(d): Deps<NotificationsDeps>,
AuthUser(uid): AuthUser,
) -> Result<Json<serde_json::Value>, ApiError> {
let page = PageParams {
page: 1,
per_page: 20,
};
let result = uc_list_notifications(&*d.notifications, &uid, page).await?;
let unread = count_unread_notifications(&*d.notifications, &uid).await?;
Ok(Json(serde_json::json!({
"total": result.total,
"unread": unread
})))
}
#[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(
Deps(d): Deps<NotificationsDeps>,
AuthUser(uid): AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<NotificationUpdateRequest>,
) -> Result<StatusCode, ApiError> {
uc_mark_notification_read(
&*d.notifications,
&NotificationId::from_uuid(id),
&uid,
body.read,
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(patch, path = "/notifications", request_body = NotificationUpdateRequest, responses((status = 204, description = "All marked read")), security(("bearer_auth" = [])))]
pub async fn mark_all_read(
Deps(d): Deps<NotificationsDeps>,
AuthUser(uid): AuthUser,
Json(body): Json<NotificationUpdateRequest>,
) -> Result<StatusCode, ApiError> {
mark_all_notifications_read(&*d.notifications, &uid, body.read).await?;
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::make_state;
use axum::{
body::Body,
http::{header, Request},
routing::{get, patch},
Router,
};
use tower::ServiceExt;
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

@@ -0,0 +1,207 @@
use crate::{
deps_struct,
errors::ApiError,
extractors::{AuthUser, Deps},
};
use api_types::requests::SetTopFriendsRequest;
use api_types::responses::TopFriendsResponse;
use crate::handlers::auth::to_user_response;
use application::use_cases::profile::{get_top_friends, get_user_by_username, set_top_friends};
use application::use_cases::social::*;
use axum::{
extract::Path,
http::StatusCode,
Json,
};
use domain::{
ports::{
BlockRepository, BoostRepository, EventPublisher, FederationActionPort, FollowRepository,
LikeRepository, TopFriendRepository, UserRepository,
},
value_objects::{ThoughtId, UserId},
};
use uuid::Uuid;
deps_struct!(SocialDeps {
likes: LikeRepository,
boosts: BoostRepository,
follows: FollowRepository,
users: UserRepository,
federation: FederationActionPort,
events: EventPublisher,
blocks: BlockRepository,
top_friends: TopFriendRepository,
});
#[utoipa::path(post, path = "/thoughts/{id}/like", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Liked")), security(("bearer_auth" = [])))]
pub async fn post_like(
Deps(d): Deps<SocialDeps>,
AuthUser(uid): AuthUser,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
like_thought(&*d.likes, &*d.events, &uid, &ThoughtId::from_uuid(id)).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(delete, path = "/thoughts/{id}/like", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unliked")), security(("bearer_auth" = [])))]
pub async fn delete_like(
Deps(d): Deps<SocialDeps>,
AuthUser(uid): AuthUser,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
unlike_thought(&*d.likes, &*d.events, &uid, &ThoughtId::from_uuid(id)).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(post, path = "/thoughts/{id}/boost", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Boosted")), security(("bearer_auth" = [])))]
pub async fn post_boost(
Deps(d): Deps<SocialDeps>,
AuthUser(uid): AuthUser,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
boost_thought(&*d.boosts, &*d.events, &uid, &ThoughtId::from_uuid(id)).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(delete, path = "/thoughts/{id}/boost", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unboosted")), security(("bearer_auth" = [])))]
pub async fn delete_boost(
Deps(d): Deps<SocialDeps>,
AuthUser(uid): AuthUser,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
unboost_thought(&*d.boosts, &*d.events, &uid, &ThoughtId::from_uuid(id)).await?;
Ok(StatusCode::NO_CONTENT)
}
#[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(
Deps(d): Deps<SocialDeps>,
AuthUser(uid): AuthUser,
Path(username): Path<String>,
) -> Result<StatusCode, ApiError> {
follow_actor(
&*d.follows,
&*d.users,
&*d.federation,
&*d.events,
&uid,
&username,
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[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(
Deps(d): Deps<SocialDeps>,
AuthUser(uid): AuthUser,
Path(username): Path<String>,
) -> Result<StatusCode, ApiError> {
unfollow_actor(
&*d.follows,
&*d.users,
&*d.federation,
&*d.events,
&uid,
&username,
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[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(
Deps(d): Deps<SocialDeps>,
AuthUser(uid): AuthUser,
Path(username): Path<String>,
) -> Result<StatusCode, ApiError> {
block_by_username(&*d.blocks, &*d.users, &*d.events, &uid, &username).await?;
Ok(StatusCode::NO_CONTENT)
}
#[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(
Deps(d): Deps<SocialDeps>,
AuthUser(uid): AuthUser,
Path(username): Path<String>,
) -> Result<StatusCode, ApiError> {
unblock_by_username(&*d.blocks, &*d.users, &*d.events, &uid, &username).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(put, path = "/users/me/top-friends", request_body = SetTopFriendsRequest, responses((status = 204, description = "Top friends updated")), security(("bearer_auth" = [])))]
pub async fn put_top_friends(
Deps(d): Deps<SocialDeps>,
AuthUser(uid): AuthUser,
Json(body): Json<SetTopFriendsRequest>,
) -> Result<StatusCode, ApiError> {
let ids: Vec<UserId> = body.friend_ids.into_iter().map(UserId::from_uuid).collect();
set_top_friends(&*d.top_friends, &uid, ids).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(get, path = "/users/{username}/top-friends",
params(("username" = String, Path, description = "Username")),
responses((status = 200, description = "Top friends list", body = TopFriendsResponse)))]
pub async fn get_top_friends_handler(
Deps(d): Deps<SocialDeps>,
Path(username): Path<String>,
) -> Result<Json<TopFriendsResponse>, ApiError> {
let user = get_user_by_username(&*d.users, &username).await?;
let friends = get_top_friends(&*d.top_friends, &user.id).await?;
let top_friends = friends.iter().map(|(_, u)| to_user_response(u)).collect();
Ok(Json(TopFriendsResponse { top_friends }))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::make_state;
use axum::{
body::Body,
http::Request,
routing::{delete, post},
Router,
};
use tower::ServiceExt;
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

@@ -0,0 +1,179 @@
use crate::{
deps_struct,
errors::ApiError,
extractors::{AuthUser, Deps, OptionalAuthUser},
handlers::feed::to_thought_response,
};
use api_types::{
requests::{CreateThoughtRequest, EditThoughtRequest},
responses::ErrorResponse,
};
use application::use_cases::thoughts::{
create_thought, delete_thought, edit_thought, get_thread_views, get_thought_view,
CreateThoughtInput,
};
use axum::{
extract::Path,
http::StatusCode,
response::IntoResponse,
Json,
};
use domain::{
models::feed::{EngagementStats, FeedEntry, ViewerContext},
ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserRepository},
value_objects::ThoughtId,
};
use uuid::Uuid;
deps_struct!(ThoughtsDeps {
thoughts: ThoughtRepository,
users: UserRepository,
tags: TagRepository,
events: EventPublisher,
outbox: OutboxWriter,
engagement: EngagementRepository,
});
#[utoipa::path(
post, path = "/thoughts",
request_body = CreateThoughtRequest,
responses(
(status = 201, description = "Thought created"),
(status = 401, description = "Unauthorized", body = ErrorResponse),
(status = 422, description = "Content too long", body = ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn post_thought(
Deps(d): Deps<ThoughtsDeps>,
AuthUser(uid): AuthUser,
Json(body): Json<CreateThoughtRequest>,
) -> Result<impl IntoResponse, ApiError> {
let in_reply_to = body.in_reply_to_id.map(ThoughtId::from_uuid);
let out = create_thought(
&*d.thoughts,
&*d.users,
&*d.tags,
&*d.events,
&*d.outbox,
CreateThoughtInput {
user_id: uid.clone(),
content: body.content,
in_reply_to_id: in_reply_to,
visibility: body.visibility,
content_warning: body.content_warning,
sensitive: body.sensitive.unwrap_or(false),
},
)
.await?;
let author = d
.users
.find_by_id(&uid)
.await?
.ok_or(domain::errors::DomainError::NotFound)?;
let entry = FeedEntry {
thought: out.thought,
author,
stats: EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 },
viewer: Some(ViewerContext { liked: false, boosted: false }),
};
Ok((StatusCode::CREATED, Json(to_thought_response(&entry))))
}
#[utoipa::path(
get, path = "/thoughts/{id}",
params(("id" = uuid::Uuid, Path, description = "Thought ID")),
responses(
(status = 200, description = "Thought with author info"),
(status = 404, description = "Not found", body = ErrorResponse),
)
)]
pub async fn get_thought_handler(
Deps(d): Deps<ThoughtsDeps>,
Path(id): Path<Uuid>,
OptionalAuthUser(viewer): OptionalAuthUser,
) -> Result<Json<serde_json::Value>, ApiError> {
let entry = get_thought_view(
&*d.thoughts,
&*d.users,
&*d.engagement,
&ThoughtId::from_uuid(id),
viewer.as_ref(),
)
.await?;
Ok(Json(serde_json::to_value(to_thought_response(&entry)).unwrap()))
}
#[utoipa::path(
delete, path = "/thoughts/{id}",
params(("id" = uuid::Uuid, Path, description = "Thought ID")),
responses(
(status = 204, description = "Deleted"),
(status = 401, description = "Unauthorized", body = ErrorResponse),
(status = 404, description = "Not found or not owner", body = ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn delete_thought_handler(
Deps(d): Deps<ThoughtsDeps>,
AuthUser(uid): AuthUser,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
delete_thought(&*d.thoughts, &*d.events, &*d.outbox, &ThoughtId::from_uuid(id), &uid).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
patch, path = "/thoughts/{id}",
params(("id" = uuid::Uuid, Path, description = "Thought ID")),
request_body = EditThoughtRequest,
responses(
(status = 204, description = "Updated"),
(status = 401, description = "Unauthorized", body = ErrorResponse),
(status = 404, description = "Not found or not owner", body = ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn patch_thought(
Deps(d): Deps<ThoughtsDeps>,
AuthUser(uid): AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<EditThoughtRequest>,
) -> Result<StatusCode, ApiError> {
edit_thought(
&*d.thoughts,
&*d.events,
&ThoughtId::from_uuid(id),
&uid,
body.content,
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
get, path = "/thoughts/{id}/thread",
params(("id" = uuid::Uuid, Path, description = "Root thought ID")),
responses(
(status = 200, description = "Thread (root + replies)"),
)
)]
pub async fn get_thread_handler(
Deps(d): Deps<ThoughtsDeps>,
Path(id): Path<Uuid>,
OptionalAuthUser(viewer): OptionalAuthUser,
) -> Result<Json<Vec<serde_json::Value>>, ApiError> {
let entries = get_thread_views(
&*d.thoughts,
&*d.users,
&*d.engagement,
&ThoughtId::from_uuid(id),
viewer.as_ref(),
)
.await?;
let items: Vec<_> = entries
.iter()
.map(|e| serde_json::to_value(to_thought_response(e)).unwrap())
.collect();
Ok(Json(items))
}

View File

@@ -0,0 +1,291 @@
use crate::{
errors::ApiError,
extractors::{AuthUser, Deps, FromAppState, OptionalAuthUser},
handlers::auth::to_user_response,
state::AppState,
};
use api_types::{
requests::{PaginationQuery, UpdateProfileRequest},
responses::{ErrorResponse, ProfileField, RemoteActorResponse, UserResponse},
};
use application::use_cases::profile::{
get_user as fetch_user, get_user_by_id_or_username, update_profile,
};
use axum::{
extract::{Path, Query},
http::{header, HeaderMap},
response::{IntoResponse, Response},
Json,
};
use domain::ports::{
EventPublisher, FederationActionPort, FollowRepository, SearchPort, UserRepository,
};
use std::sync::Arc;
pub struct UsersDeps {
pub users: Arc<dyn UserRepository>,
pub events: Arc<dyn EventPublisher>,
pub follows: Arc<dyn FollowRepository>,
pub federation: Arc<dyn FederationActionPort>,
pub search: Arc<dyn SearchPort>,
}
impl FromAppState for UsersDeps {
fn from_state(s: &AppState) -> Self {
Self {
users: s.users.clone(),
events: s.events.clone(),
follows: s.follows.clone(),
federation: s.federation.clone(),
search: s.search.clone(),
}
}
}
#[utoipa::path(
get, path = "/users/{username}",
params(("username" = String, Path, description = "Username")),
responses(
(status = 200, body = UserResponse),
(status = 404, description = "User not found", body = ErrorResponse),
)
)]
pub async fn get_user(
Deps(d): Deps<UsersDeps>,
Path(username): Path<String>,
OptionalAuthUser(viewer): OptionalAuthUser,
headers: HeaderMap,
) -> Result<Response, ApiError> {
let user = get_user_by_id_or_username(&*d.users, &username).await?;
let accept = headers
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if accept.contains("application/activity+json") {
let json = d.federation.actor_json(&user.id).await?;
Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response())
} else {
let is_followed = if let Some(viewer_id) = viewer {
d.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(
patch, path = "/users/me",
request_body = UpdateProfileRequest,
responses(
(status = 200, body = UserResponse),
(status = 401, description = "Unauthorized", body = ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn patch_profile(
Deps(d): Deps<UsersDeps>,
AuthUser(uid): AuthUser,
Json(body): Json<UpdateProfileRequest>,
) -> Result<Json<UserResponse>, ApiError> {
update_profile(
&*d.users,
&*d.events,
&uid,
body.display_name,
body.bio,
body.avatar_url,
body.header_url,
body.custom_css,
)
.await?;
let user = fetch_user(&*d.users, &uid).await?;
Ok(Json(to_user_response(&user)))
}
#[utoipa::path(
get, path = "/users/me",
responses(
(status = 200, body = UserResponse),
(status = 401, description = "Unauthorized", body = ErrorResponse),
),
security(("bearer_auth" = []))
)]
pub async fn get_me(
Deps(d): Deps<UsersDeps>,
AuthUser(uid): AuthUser,
) -> Result<Json<UserResponse>, ApiError> {
let user = fetch_user(&*d.users, &uid).await?;
Ok(Json(to_user_response(&user)))
}
pub async fn get_me_following(
Deps(d): Deps<UsersDeps>,
AuthUser(uid): AuthUser,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
use domain::models::feed::PageParams;
let page = PageParams {
page: q.page(),
per_page: q.per_page(),
};
let result = d.follows.list_following(&uid, &page).await?;
Ok(Json(serde_json::json!({
"total": result.total,
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>(),
})))
}
pub async fn get_users(
Deps(d): Deps<UsersDeps>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, ApiError> {
use domain::models::feed::PageParams;
let page = params
.get("page")
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(1);
let per_page = params
.get("per_page")
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(20);
let page_params = PageParams { page, per_page };
if let Some(q) = params.get("q").filter(|q| !q.trim().is_empty()) {
let result = d.search.search_users(q, &page_params).await?;
let users: Vec<_> = result
.items
.iter()
.map(crate::handlers::auth::to_user_response)
.collect();
return Ok(Json(serde_json::json!({
"items": users, "total": result.total, "page": result.page, "per_page": result.per_page
})));
}
let result = d.users.list_paginated(page_params).await?;
let items: Vec<_> = result
.items
.iter()
.map(|u| {
serde_json::json!({
"id": u.id.as_uuid(),
"username": u.username,
"displayName": u.display_name,
"avatarUrl": u.avatar_url,
"bio": u.bio,
"headerUrl": null,
"customCss": null,
"local": true,
"isFollowedByViewer": false,
"joinedAt": null,
})
})
.collect();
Ok(Json(serde_json::json!({
"items": items, "total": result.total, "page": result.page, "per_page": result.per_page
})))
}
pub async fn get_user_count(
Deps(d): Deps<UsersDeps>,
) -> Result<Json<serde_json::Value>, ApiError> {
let count = d.users.count().await?;
Ok(Json(serde_json::json!({ "count": count })))
}
#[derive(serde::Deserialize)]
pub struct LookupQuery {
pub handle: String,
}
pub async fn lookup_handler(
Deps(d): Deps<UsersDeps>,
Query(q): Query<LookupQuery>,
) -> Result<Json<RemoteActorResponse>, ApiError> {
let actor = d.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,
bio: actor.bio,
banner_url: actor.banner_url,
also_known_as: actor.also_known_as,
outbox_url: actor.outbox_url,
followers_url: actor.followers_url,
following_url: actor.following_url,
attachment: actor
.attachment
.into_iter()
.map(|(name, value)| ProfileField { name, value })
.collect(),
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::make_state;
use axum::{
body::Body,
http::{header, Request},
routing::get,
Router,
};
use tower::ServiceExt;
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);
}
}