feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready #1

Merged
GKaszewski merged 334 commits from v2 into master 2026-05-16 09:42:43 +00:00
3 changed files with 126 additions and 10 deletions
Showing only changes of commit abc5f2b936 - Show all commits

View File

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

View File

@@ -5,7 +5,12 @@ use axum::{
}; };
use serde::Deserialize; use serde::Deserialize;
use api_types::{requests::FollowRemoteRequest, responses::RemoteActorResponse}; use api_types::responses::RemoteActorResponse;
#[derive(serde::Deserialize)]
pub struct FollowRemoteRequest {
pub handle: String,
}
use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; use crate::{errors::ApiError, extractors::AuthUser, state::AppState};

View File

@@ -1,4 +1,5 @@
use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
use api_types::requests::NotificationUpdateRequest;
use application::use_cases::notifications::{ use application::use_cases::notifications::{
list_notifications as uc_list_notifications, mark_all_notifications_read, list_notifications as uc_list_notifications, mark_all_notifications_read,
mark_notification_read as uc_mark_notification_read, mark_notification_read as uc_mark_notification_read,
@@ -21,26 +22,136 @@ pub async fn list_notifications(
per_page: 20, per_page: 20,
}; };
let result = uc_list_notifications(&*s.notifications, &uid, page).await?; let result = uc_list_notifications(&*s.notifications, &uid, page).await?;
Ok(Json( Ok(Json(serde_json::json!({
serde_json::json!({ "total": result.total, "unread": result.items.iter().filter(|n| !n.read).count() }), "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( pub async fn mark_notification_read(
State(s): State<AppState>, State(s): State<AppState>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(body): Json<NotificationUpdateRequest>,
) -> Result<StatusCode, ApiError> { ) -> 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) 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( pub async fn mark_all_read(
State(s): State<AppState>, State(s): State<AppState>,
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
Json(body): Json<NotificationUpdateRequest>,
) -> Result<StatusCode, ApiError> { ) -> 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) 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);
}
}