feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready (#1)
This commit was merged in pull request #1.
This commit is contained in:
28
crates/presentation/Cargo.toml
Normal file
28
crates/presentation/Cargo.toml
Normal file
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "presentation"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
activitypub-base = { workspace = true }
|
||||
application = { workspace = true }
|
||||
api-types = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
url = { workspace = true }
|
||||
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] }
|
||||
utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false }
|
||||
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }
|
||||
|
||||
[dev-dependencies]
|
||||
http-body-util = "0.1"
|
||||
tower = "0.5"
|
||||
domain = { workspace = true, features = ["test-helpers"] }
|
||||
46
crates/presentation/src/errors.rs
Normal file
46
crates/presentation/src/errors.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use api_types::responses::ErrorResponse;
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use domain::errors::DomainError;
|
||||
|
||||
pub enum ApiError {
|
||||
Domain(DomainError),
|
||||
Unauthorized,
|
||||
BadRequest(String),
|
||||
}
|
||||
|
||||
impl From<DomainError> for ApiError {
|
||||
fn from(e: DomainError) -> Self {
|
||||
Self::Domain(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, msg) = match self {
|
||||
Self::Domain(DomainError::NotFound) => (StatusCode::NOT_FOUND, "not found".into()),
|
||||
Self::Domain(DomainError::Unauthorized) => {
|
||||
(StatusCode::UNAUTHORIZED, "unauthorized".into())
|
||||
}
|
||||
Self::Domain(DomainError::Forbidden) => (StatusCode::FORBIDDEN, "forbidden".into()),
|
||||
Self::Domain(DomainError::Conflict(m)) => (StatusCode::CONFLICT, m),
|
||||
Self::Domain(DomainError::UniqueViolation { field }) => {
|
||||
(StatusCode::CONFLICT, format!("{field} already taken"))
|
||||
}
|
||||
Self::Domain(DomainError::InvalidInput(m)) => (StatusCode::UNPROCESSABLE_ENTITY, m),
|
||||
Self::Domain(DomainError::ExternalService(_)) => {
|
||||
(StatusCode::BAD_GATEWAY, "external service error".into())
|
||||
}
|
||||
Self::Domain(DomainError::Internal(_)) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal server error".into(),
|
||||
),
|
||||
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()),
|
||||
Self::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
|
||||
};
|
||||
(status, Json(ErrorResponse { error: msg })).into_response()
|
||||
}
|
||||
}
|
||||
93
crates/presentation/src/extractors.rs
Normal file
93
crates/presentation/src/extractors.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use crate::{errors::ApiError, state::AppState};
|
||||
use axum::{extract::FromRequestParts, http::request::Parts};
|
||||
use domain::value_objects::UserId;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deps_struct! — generates Deps struct + impl FromAppState from a field list.
|
||||
// Field names must match AppState exactly (enforced at compile time).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! deps_struct {
|
||||
( $name:ident { $( $field:ident : $trait:path ),+ $(,)? } ) => {
|
||||
pub struct $name {
|
||||
$( pub $field: ::std::sync::Arc<dyn $trait>, )+
|
||||
}
|
||||
impl $crate::extractors::FromAppState for $name {
|
||||
fn from_state(s: &$crate::state::AppState) -> Self {
|
||||
Self {
|
||||
$( $field: ::std::sync::Arc::clone(&s.$field), )+
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deps<S> extractor — narrows AppState to a handler-specific deps struct
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct Deps<S>(pub S);
|
||||
|
||||
pub trait FromAppState: Sized {
|
||||
fn from_state(s: &AppState) -> Self;
|
||||
}
|
||||
|
||||
impl<S: FromAppState + Send + 'static> FromRequestParts<AppState> for Deps<S> {
|
||||
type Rejection = std::convert::Infallible;
|
||||
|
||||
async fn from_request_parts(
|
||||
_parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
Ok(Deps(S::from_state(state)))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth extractors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct AuthUser(pub UserId);
|
||||
pub struct OptionalAuthUser(pub Option<UserId>);
|
||||
|
||||
impl FromRequestParts<AppState> for AuthUser {
|
||||
type Rejection = ApiError;
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, ApiError> {
|
||||
extract_user_id(parts, state)
|
||||
.await?
|
||||
.ok_or(ApiError::Unauthorized)
|
||||
.map(AuthUser)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for OptionalAuthUser {
|
||||
type Rejection = ApiError;
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, ApiError> {
|
||||
Ok(OptionalAuthUser(extract_user_id(parts, state).await?))
|
||||
}
|
||||
}
|
||||
|
||||
async fn extract_user_id(parts: &mut Parts, state: &AppState) -> Result<Option<UserId>, ApiError> {
|
||||
if let Some(auth_header) = parts.headers.get("Authorization") {
|
||||
if let Ok(s) = auth_header.to_str() {
|
||||
if let Some(token) = s.strip_prefix("Bearer ") {
|
||||
return state
|
||||
.auth
|
||||
.validate_token(token)
|
||||
.map(Some)
|
||||
.map_err(|_| ApiError::Unauthorized);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(key_header) = parts.headers.get("X-Api-Key") {
|
||||
if let Ok(raw) = key_header.to_str() {
|
||||
return state
|
||||
.api_key_auth
|
||||
.validate_key(raw)
|
||||
.await
|
||||
.map_err(|_| ApiError::Unauthorized);
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
58
crates/presentation/src/handlers/api_keys.rs
Normal file
58
crates/presentation/src/handlers/api_keys.rs
Normal 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)
|
||||
}
|
||||
94
crates/presentation/src/handlers/auth.rs
Normal file
94
crates/presentation/src/handlers/auth.rs
Normal 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),
|
||||
}))
|
||||
}
|
||||
151
crates/presentation/src/handlers/federation_actors.rs
Normal file
151
crates/presentation/src/handlers/federation_actors.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
109
crates/presentation/src/handlers/federation_management.rs
Normal file
109
crates/presentation/src/handlers/federation_management.rs
Normal 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)
|
||||
}
|
||||
279
crates/presentation/src/handlers/feed.rs
Normal file
279
crates/presentation/src/handlers/feed.rs
Normal 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, ¶m).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, ¶m).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, ¶m).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, ¶m).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<_>>(),
|
||||
})))
|
||||
}
|
||||
28
crates/presentation/src/handlers/health.rs
Normal file
28
crates/presentation/src/handlers/health.rs
Normal 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" },
|
||||
}))
|
||||
}
|
||||
10
crates/presentation/src/handlers/mod.rs
Normal file
10
crates/presentation/src/handlers/mod.rs
Normal 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;
|
||||
119
crates/presentation/src/handlers/notifications.rs
Normal file
119
crates/presentation/src/handlers/notifications.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
207
crates/presentation/src/handlers/social.rs
Normal file
207
crates/presentation/src/handlers/social.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
179
crates/presentation/src/handlers/thoughts.rs
Normal file
179
crates/presentation/src/handlers/thoughts.rs
Normal 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))
|
||||
}
|
||||
291
crates/presentation/src/handlers/users.rs
Normal file
291
crates/presentation/src/handlers/users.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
10
crates/presentation/src/lib.rs
Normal file
10
crates/presentation/src/lib.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
pub mod errors;
|
||||
pub mod extractors;
|
||||
pub mod handlers;
|
||||
pub mod openapi;
|
||||
pub mod routes;
|
||||
pub mod state;
|
||||
#[cfg(test)]
|
||||
pub mod testing;
|
||||
|
||||
pub use extractors::{Deps, FromAppState};
|
||||
16
crates/presentation/src/openapi/api_keys.rs
Normal file
16
crates/presentation/src/openapi/api_keys.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use api_types::{
|
||||
requests::CreateApiKeyRequest,
|
||||
responses::{ApiKeyResponse, CreatedApiKeyResponse},
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::handlers::api_keys::get_api_keys,
|
||||
crate::handlers::api_keys::post_api_key,
|
||||
crate::handlers::api_keys::delete_api_key_handler,
|
||||
),
|
||||
components(schemas(CreateApiKeyRequest, ApiKeyResponse, CreatedApiKeyResponse))
|
||||
)]
|
||||
pub struct ApiKeysDoc;
|
||||
15
crates/presentation/src/openapi/auth.rs
Normal file
15
crates/presentation/src/openapi/auth.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use api_types::{
|
||||
requests::{LoginRequest, RegisterRequest},
|
||||
responses::{AuthResponse, ErrorResponse},
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::handlers::auth::post_register,
|
||||
crate::handlers::auth::post_login
|
||||
),
|
||||
components(schemas(RegisterRequest, LoginRequest, AuthResponse, ErrorResponse))
|
||||
)]
|
||||
pub struct AuthDoc;
|
||||
11
crates/presentation/src/openapi/feed.rs
Normal file
11
crates/presentation/src/openapi/feed.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(paths(
|
||||
crate::handlers::feed::home_feed,
|
||||
crate::handlers::feed::public_feed,
|
||||
crate::handlers::feed::search_handler,
|
||||
crate::handlers::feed::user_thoughts_handler,
|
||||
crate::handlers::feed::tag_thoughts_handler,
|
||||
))]
|
||||
pub struct FeedDoc;
|
||||
5
crates/presentation/src/openapi/health.rs
Normal file
5
crates/presentation/src/openapi/health.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(paths(crate::handlers::health::health_handler))]
|
||||
pub struct HealthDoc;
|
||||
61
crates/presentation/src/openapi/mod.rs
Normal file
61
crates/presentation/src/openapi/mod.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
mod api_keys;
|
||||
mod auth;
|
||||
mod feed;
|
||||
mod health;
|
||||
mod notifications;
|
||||
mod social;
|
||||
mod thoughts;
|
||||
mod users;
|
||||
|
||||
use axum::Router;
|
||||
use utoipa::{
|
||||
openapi::security::{ApiKey, ApiKeyValue, Http, HttpAuthScheme, SecurityScheme},
|
||||
Modify, OpenApi,
|
||||
};
|
||||
use utoipa_scalar::{Scalar, Servable};
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
struct SecurityAddon;
|
||||
|
||||
impl Modify for SecurityAddon {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
let components = openapi.components.get_or_insert_with(Default::default);
|
||||
components.add_security_scheme(
|
||||
"bearer_auth",
|
||||
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
|
||||
);
|
||||
components.add_security_scheme(
|
||||
"api_key",
|
||||
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("X-Api-Key"))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn build() -> utoipa::openapi::OpenApi {
|
||||
let mut api = auth::AuthDoc::openapi();
|
||||
api.info = utoipa::openapi::InfoBuilder::new()
|
||||
.title("Thoughts API")
|
||||
.version("2.0.0")
|
||||
.description(Some(
|
||||
"Federated social network API. Authenticate via `POST /auth/login` to get a Bearer token, \
|
||||
or use `X-Api-Key` header with a key from `POST /api-keys`."
|
||||
))
|
||||
.build();
|
||||
api.merge(users::UsersDoc::openapi());
|
||||
api.merge(thoughts::ThoughtsDoc::openapi());
|
||||
api.merge(feed::FeedDoc::openapi());
|
||||
api.merge(social::SocialDoc::openapi());
|
||||
api.merge(notifications::NotificationsDoc::openapi());
|
||||
api.merge(api_keys::ApiKeysDoc::openapi());
|
||||
api.merge(health::HealthDoc::openapi());
|
||||
SecurityAddon.modify(&mut api);
|
||||
api
|
||||
}
|
||||
|
||||
pub fn serve<S: Clone + Send + Sync + 'static>(router: Router<S>) -> Router<S> {
|
||||
tracing::info!("API docs at /docs (Swagger UI) and /scalar (Scalar)");
|
||||
let spec = build();
|
||||
router
|
||||
.merge(SwaggerUi::new("/docs").url("/openapi.json", spec.clone()))
|
||||
.merge(Scalar::with_url("/scalar", spec))
|
||||
}
|
||||
9
crates/presentation/src/openapi/notifications.rs
Normal file
9
crates/presentation/src/openapi/notifications.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(paths(
|
||||
crate::handlers::notifications::list_notifications,
|
||||
crate::handlers::notifications::mark_notification_read,
|
||||
crate::handlers::notifications::mark_all_read,
|
||||
))]
|
||||
pub struct NotificationsDoc;
|
||||
20
crates/presentation/src/openapi/social.rs
Normal file
20
crates/presentation/src/openapi/social.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use api_types::requests::SetTopFriendsRequest;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::handlers::social::post_like,
|
||||
crate::handlers::social::delete_like,
|
||||
crate::handlers::social::post_boost,
|
||||
crate::handlers::social::delete_boost,
|
||||
crate::handlers::social::post_follow,
|
||||
crate::handlers::social::delete_follow,
|
||||
crate::handlers::social::post_block,
|
||||
crate::handlers::social::delete_block,
|
||||
crate::handlers::social::put_top_friends,
|
||||
crate::handlers::social::get_top_friends_handler,
|
||||
),
|
||||
components(schemas(SetTopFriendsRequest))
|
||||
)]
|
||||
pub struct SocialDoc;
|
||||
18
crates/presentation/src/openapi/thoughts.rs
Normal file
18
crates/presentation/src/openapi/thoughts.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use api_types::{
|
||||
requests::{CreateThoughtRequest, EditThoughtRequest},
|
||||
responses::ErrorResponse,
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::handlers::thoughts::post_thought,
|
||||
crate::handlers::thoughts::get_thought_handler,
|
||||
crate::handlers::thoughts::patch_thought,
|
||||
crate::handlers::thoughts::delete_thought_handler,
|
||||
crate::handlers::thoughts::get_thread_handler,
|
||||
),
|
||||
components(schemas(CreateThoughtRequest, EditThoughtRequest, ErrorResponse))
|
||||
)]
|
||||
pub struct ThoughtsDoc;
|
||||
16
crates/presentation/src/openapi/users.rs
Normal file
16
crates/presentation/src/openapi/users.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use api_types::{
|
||||
requests::UpdateProfileRequest,
|
||||
responses::{ErrorResponse, UserResponse},
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::handlers::users::get_me,
|
||||
crate::handlers::users::get_user,
|
||||
crate::handlers::users::patch_profile,
|
||||
),
|
||||
components(schemas(UserResponse, UpdateProfileRequest, ErrorResponse))
|
||||
)]
|
||||
pub struct UsersDoc;
|
||||
117
crates/presentation/src/routes.rs
Normal file
117
crates/presentation/src/routes.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use crate::{handlers::*, openapi, state::AppState};
|
||||
use axum::{
|
||||
routing::{delete, get, patch, post, put},
|
||||
Router,
|
||||
};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
let api_routes = Router::new()
|
||||
// health
|
||||
.route("/health", get(health::health_handler))
|
||||
// auth
|
||||
.route("/auth/register", post(auth::post_register))
|
||||
.route("/auth/login", post(auth::post_login))
|
||||
// users — static before parameterised
|
||||
.route("/users", get(users::get_users))
|
||||
.route("/users/count", get(users::get_user_count))
|
||||
.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))
|
||||
.route("/users/{username}", get(users::get_user))
|
||||
.route(
|
||||
"/users/{username}/top-friends",
|
||||
get(social::get_top_friends_handler),
|
||||
)
|
||||
.route(
|
||||
"/users/{username}/follow",
|
||||
post(social::post_follow).delete(social::delete_follow),
|
||||
)
|
||||
.route(
|
||||
"/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(
|
||||
"/thoughts/{id}",
|
||||
get(thoughts::get_thought_handler)
|
||||
.patch(thoughts::patch_thought)
|
||||
.delete(thoughts::delete_thought_handler),
|
||||
)
|
||||
.route("/thoughts/{id}/thread", get(thoughts::get_thread_handler))
|
||||
// likes & boosts
|
||||
.route(
|
||||
"/thoughts/{id}/like",
|
||||
post(social::post_like).delete(social::delete_like),
|
||||
)
|
||||
.route(
|
||||
"/thoughts/{id}/boost",
|
||||
post(social::post_boost).delete(social::delete_boost),
|
||||
)
|
||||
// feeds
|
||||
.route("/feed", get(feed::home_feed))
|
||||
.route("/feed/public", get(feed::public_feed))
|
||||
.route("/search", get(feed::search_handler))
|
||||
.route(
|
||||
"/federation/actors/{handle}/posts",
|
||||
get(federation_actors::remote_actor_posts_handler),
|
||||
)
|
||||
.route(
|
||||
"/federation/actors/{handle}/followers-list",
|
||||
get(federation_actors::actor_followers_handler),
|
||||
)
|
||||
.route(
|
||||
"/federation/actors/{handle}/following-list",
|
||||
get(federation_actors::actor_following_handler),
|
||||
)
|
||||
.route(
|
||||
"/federation/me/followers/pending",
|
||||
get(federation_management::get_pending_requests),
|
||||
)
|
||||
.route(
|
||||
"/federation/me/followers/accept",
|
||||
post(federation_management::post_accept_request),
|
||||
)
|
||||
.route(
|
||||
"/federation/me/followers",
|
||||
get(federation_management::get_remote_followers)
|
||||
.delete(federation_management::delete_follower),
|
||||
)
|
||||
.route(
|
||||
"/federation/me/following",
|
||||
get(federation_management::get_remote_following)
|
||||
.delete(federation_management::delete_following),
|
||||
)
|
||||
.route("/tags/popular", get(feed::get_popular_tags))
|
||||
.route("/tags/{name}", get(feed::tag_thoughts_handler))
|
||||
// notifications
|
||||
.route(
|
||||
"/notifications",
|
||||
get(notifications::list_notifications).patch(notifications::mark_all_read),
|
||||
)
|
||||
.route(
|
||||
"/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));
|
||||
|
||||
openapi::serve(api_routes)
|
||||
}
|
||||
30
crates/presentation/src/state.rs
Normal file
30
crates/presentation/src/state.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use activitypub_base::ActivityPubRepository;
|
||||
use domain::ports::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub users: Arc<dyn UserRepository>,
|
||||
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||
pub likes: Arc<dyn LikeRepository>,
|
||||
pub boosts: Arc<dyn BoostRepository>,
|
||||
pub follows: Arc<dyn FollowRepository>,
|
||||
pub blocks: Arc<dyn BlockRepository>,
|
||||
pub tags: Arc<dyn TagRepository>,
|
||||
pub api_keys: Arc<dyn ApiKeyRepository>,
|
||||
pub api_key_auth: Arc<dyn ApiKeyService>,
|
||||
pub top_friends: Arc<dyn TopFriendRepository>,
|
||||
pub notifications: Arc<dyn NotificationRepository>,
|
||||
pub remote_actors: Arc<dyn RemoteActorRepository>,
|
||||
pub feed: Arc<dyn FeedRepository>,
|
||||
pub search: Arc<dyn SearchPort>,
|
||||
pub auth: Arc<dyn AuthService>,
|
||||
pub hasher: Arc<dyn PasswordHasher>,
|
||||
pub events: Arc<dyn EventPublisher>,
|
||||
pub outbox: Arc<dyn OutboxWriter>,
|
||||
pub federation: Arc<dyn FederationActionPort>,
|
||||
pub ap_repo: Arc<dyn ActivityPubRepository>,
|
||||
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
|
||||
pub federation_scheduler: Arc<dyn FederationSchedulerPort>,
|
||||
pub engagement: Arc<dyn EngagementRepository>,
|
||||
}
|
||||
139
crates/presentation/src/testing.rs
Normal file
139
crates/presentation/src/testing.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use crate::state::AppState;
|
||||
use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry};
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||
testing::{NoOpOutboxWriter, TestStore},
|
||||
value_objects::{PasswordHash, ThoughtId, UserId},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub 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)
|
||||
}
|
||||
}
|
||||
|
||||
pub 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// No-op ActivityPubRepository for presentation layer tests.
|
||||
pub struct NoOpApRepo;
|
||||
|
||||
#[async_trait]
|
||||
impl ActivityPubRepository for NoOpApRepo {
|
||||
async fn outbox_entries_for_actor(
|
||||
&self,
|
||||
_uid: &UserId,
|
||||
) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn outbox_page_for_actor(
|
||||
&self,
|
||||
_uid: &UserId,
|
||||
_before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
_limit: usize,
|
||||
) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn find_remote_actor_id(
|
||||
&self,
|
||||
_actor_ap_url: &str,
|
||||
) -> Result<Option<UserId>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn intern_remote_actor(&self, _actor_ap_url: &str) -> Result<UserId, DomainError> {
|
||||
Err(DomainError::NotFound)
|
||||
}
|
||||
async fn update_remote_actor_display(
|
||||
&self,
|
||||
_user_id: &UserId,
|
||||
_display_name: Option<&str>,
|
||||
_avatar_url: Option<&str>,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn accept_note(
|
||||
&self,
|
||||
_ap_id: &str,
|
||||
_author_id: &UserId,
|
||||
_content: &str,
|
||||
_published: chrono::DateTime<chrono::Utc>,
|
||||
_sensitive: bool,
|
||||
_content_warning: Option<String>,
|
||||
_visibility: &str,
|
||||
_in_reply_to: Option<&str>,
|
||||
) -> Result<ThoughtId, DomainError> {
|
||||
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4()))
|
||||
}
|
||||
async fn apply_note_update(
|
||||
&self,
|
||||
_ap_id: &str,
|
||||
_new_content: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn retract_actor_notes(&self, _actor_ap_url: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn count_local_notes(&self) -> Result<u64, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn get_thought_ap_id(
|
||||
&self,
|
||||
_thought_id: &ThoughtId,
|
||||
) -> Result<Option<String>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn get_actor_ap_urls(
|
||||
&self,
|
||||
_user_id: &UserId,
|
||||
) -> Result<Option<ActorApUrls>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn make_state() -> AppState {
|
||||
let store = Arc::new(TestStore::default());
|
||||
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(),
|
||||
outbox: Arc::new(NoOpOutboxWriter),
|
||||
federation: store.clone(),
|
||||
ap_repo: Arc::new(NoOpApRepo),
|
||||
remote_actor_connections: store.clone(),
|
||||
federation_scheduler: store.clone(),
|
||||
api_key_auth: store.clone(),
|
||||
engagement: store.clone(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user