feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready #1
@@ -110,3 +110,20 @@ pub struct RemoteActorResponse {
|
||||
pub following_url: Option<String>,
|
||||
pub attachment: Vec<ProfileField>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActorConnectionResponse {
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActorConnectionPageResponse {
|
||||
pub items: Vec<ActorConnectionResponse>,
|
||||
pub page: u32,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
|
||||
use event_transport::EventPublisherAdapter;
|
||||
use nats::NatsTransport;
|
||||
use postgres::activitypub::PgActivityPubRepository;
|
||||
use postgres::remote_actor_connections::PgRemoteActorConnectionRepository;
|
||||
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
|
||||
use presentation::state::AppState;
|
||||
|
||||
@@ -111,6 +112,7 @@ pub async fn build(cfg: &Config) -> Infrastructure {
|
||||
events: event_publisher,
|
||||
federation: ap_service.clone() as Arc<dyn domain::ports::FederationActionPort>,
|
||||
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||
remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())),
|
||||
};
|
||||
|
||||
Infrastructure { state, ap_service }
|
||||
|
||||
@@ -2,7 +2,10 @@ use crate::{
|
||||
errors::ApiError, extractors::OptionalAuthUser, handlers::feed::to_thought_response,
|
||||
state::AppState,
|
||||
};
|
||||
use api_types::requests::PaginationQuery;
|
||||
use api_types::{
|
||||
requests::PaginationQuery,
|
||||
responses::{ActorConnectionPageResponse, ActorConnectionResponse},
|
||||
};
|
||||
use application::use_cases::feed::get_user_feed;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
@@ -71,6 +74,85 @@ pub async fn remote_actor_posts_handler(
|
||||
})))
|
||||
}
|
||||
|
||||
const CACHE_TTL_SECS: i64 = 3600;
|
||||
|
||||
pub async fn actor_followers_handler(
|
||||
State(s): State<AppState>,
|
||||
Path(handle): Path<String>,
|
||||
Query(q): Query<PaginationQuery>,
|
||||
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
|
||||
actor_connections_handler(s, handle, "followers", q.page() as u32).await
|
||||
}
|
||||
|
||||
pub async fn actor_following_handler(
|
||||
State(s): State<AppState>,
|
||||
Path(handle): Path<String>,
|
||||
Query(q): Query<PaginationQuery>,
|
||||
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
|
||||
actor_connections_handler(s, handle, "following", q.page() as u32).await
|
||||
}
|
||||
|
||||
async fn actor_connections_handler(
|
||||
s: AppState,
|
||||
handle: String,
|
||||
connection_type: &str,
|
||||
page: u32,
|
||||
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
|
||||
const PAGE_SIZE: usize = 20;
|
||||
|
||||
let actor = s.federation.lookup_actor(&handle).await?;
|
||||
|
||||
let collection_url = match connection_type {
|
||||
"followers" => actor
|
||||
.followers_url
|
||||
.ok_or_else(|| ApiError::BadRequest("actor has no followers URL".into()))?,
|
||||
_ => actor
|
||||
.following_url
|
||||
.ok_or_else(|| ApiError::BadRequest("actor has no following URL".into()))?,
|
||||
};
|
||||
|
||||
let items = s
|
||||
.remote_actor_connections
|
||||
.list_connections(&actor.url, connection_type, page)
|
||||
.await?;
|
||||
|
||||
let stale = match s
|
||||
.remote_actor_connections
|
||||
.connection_page_age(&actor.url, connection_type, page)
|
||||
.await?
|
||||
{
|
||||
None => true,
|
||||
Some(age) => chrono::Utc::now().signed_duration_since(age).num_seconds() > CACHE_TTL_SECS,
|
||||
};
|
||||
|
||||
if stale {
|
||||
let _ = s
|
||||
.events
|
||||
.publish(&DomainEvent::FetchActorConnections {
|
||||
actor_ap_url: actor.url.clone(),
|
||||
collection_url,
|
||||
connection_type: connection_type.to_string(),
|
||||
page,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
let has_more = items.len() >= PAGE_SIZE;
|
||||
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::*;
|
||||
@@ -127,6 +209,7 @@ mod tests {
|
||||
events: store.clone(),
|
||||
federation: store.clone(),
|
||||
ap_repo: store.clone(),
|
||||
remote_actor_connections: store.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,13 +113,15 @@ mod tests {
|
||||
hasher: Arc::new(NoOpHasher),
|
||||
events: store.clone(),
|
||||
federation: store.clone(),
|
||||
ap_repo: store.clone(),
|
||||
remote_actor_connections: store.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn app() -> Router {
|
||||
Router::new()
|
||||
.route("/notifications", patch(mark_all_read))
|
||||
.route("/notifications/:id", patch(mark_notification_read))
|
||||
.route("/notifications/{id}", patch(mark_notification_read))
|
||||
.with_state(make_state())
|
||||
}
|
||||
|
||||
|
||||
@@ -186,6 +186,8 @@ mod tests {
|
||||
hasher: Arc::new(NoOpHasher),
|
||||
events: store.clone(),
|
||||
federation: store.clone(),
|
||||
ap_repo: store.clone(),
|
||||
remote_actor_connections: store.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -275,6 +275,7 @@ mod tests {
|
||||
events: store.clone(),
|
||||
federation: store.clone(),
|
||||
ap_repo: store.clone(),
|
||||
remote_actor_connections: store.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,14 @@ pub fn router() -> Router<AppState> {
|
||||
"/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("/tags/popular", get(feed::get_popular_tags))
|
||||
.route("/tags/{name}", get(feed::tag_thoughts_handler))
|
||||
// notifications
|
||||
|
||||
@@ -21,4 +21,5 @@ pub struct AppState {
|
||||
pub events: Arc<dyn EventPublisher>,
|
||||
pub federation: Arc<dyn FederationActionPort>,
|
||||
pub ap_repo: Arc<dyn ActivityPubRepository>,
|
||||
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user