feat(presentation): remote actor posts endpoint + extended RemoteActorResponse

This commit is contained in:
2026-05-14 22:19:23 +02:00
parent 00b369c6ad
commit f3c3637ade
6 changed files with 165 additions and 2 deletions

View File

@@ -0,0 +1,136 @@
use crate::{
errors::ApiError, extractors::OptionalAuthUser, handlers::feed::to_thought_response,
state::AppState,
};
use api_types::requests::PaginationQuery;
use application::use_cases::feed::get_user_feed;
use axum::{
extract::{Path, Query, State},
Json,
};
use domain::{events::DomainEvent, models::feed::PageParams};
pub async fn remote_actor_posts_handler(
State(s): State<AppState>,
Path(handle): Path<String>,
Query(q): Query<PaginationQuery>,
OptionalAuthUser(viewer): OptionalAuthUser,
) -> Result<Json<serde_json::Value>, ApiError> {
let actor = s.federation.lookup_actor(&handle).await?;
let ap_url = url::Url::parse(&actor.url).map_err(|e| ApiError::BadRequest(e.to_string()))?;
// Get or create interned local UserId for this remote actor
let author_id = match s.ap_repo.find_remote_actor_id(&ap_url).await? {
Some(id) => id,
None => s.ap_repo.intern_remote_actor(&ap_url).await?,
};
// Return cached posts from DB
let page = PageParams {
page: q.page(),
per_page: q.per_page(),
};
let result = get_user_feed(&*s.feed, &author_id, page, viewer.as_ref()).await?;
// Trigger background outbox fetch (fire and forget)
if let Some(outbox_url) = &actor.outbox_url {
let _ = s
.events
.publish(&DomainEvent::FetchRemoteActorPosts {
actor_ap_url: actor.url.clone(),
outbox_url: outbox_url.clone(),
})
.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<_>>(),
})))
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use axum::{body::Body, http::Request, routing::get, Router};
use domain::{
errors::DomainError,
ports::{AuthService, GeneratedToken, PasswordHasher},
testing::TestStore,
value_objects::{PasswordHash, UserId},
};
use std::sync::Arc;
use tower::ServiceExt;
struct NoOpAuth;
impl AuthService for NoOpAuth {
fn generate_token(&self, _uid: &UserId) -> Result<GeneratedToken, DomainError> {
Err(DomainError::Internal("noop".into()))
}
fn validate_token(&self, _token: &str) -> Result<UserId, DomainError> {
Err(DomainError::Unauthorized)
}
}
struct NoOpHasher;
#[async_trait]
impl PasswordHasher for NoOpHasher {
async fn hash(&self, _plain: &str) -> Result<PasswordHash, DomainError> {
Err(DomainError::Internal("noop".into()))
}
async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result<bool, DomainError> {
Ok(false)
}
}
fn make_state() -> crate::state::AppState {
let store = Arc::new(TestStore::default());
crate::state::AppState {
users: store.clone(),
thoughts: store.clone(),
likes: store.clone(),
boosts: store.clone(),
follows: store.clone(),
blocks: store.clone(),
tags: store.clone(),
api_keys: store.clone(),
top_friends: store.clone(),
notifications: store.clone(),
remote_actors: store.clone(),
feed: store.clone(),
search: store.clone(),
auth: Arc::new(NoOpAuth),
hasher: Arc::new(NoOpHasher),
events: store.clone(),
federation: store.clone(),
ap_repo: store.clone(),
}
}
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

@@ -21,7 +21,7 @@ use axum::{
use domain::models::feed::PageParams;
use domain::value_objects::UserId;
fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
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(),

View File

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

View File

@@ -6,7 +6,7 @@ use crate::{
};
use api_types::{
requests::{PaginationQuery, UpdateProfileRequest},
responses::{ErrorResponse, RemoteActorResponse, UserResponse},
responses::{ErrorResponse, ProfileField, RemoteActorResponse, UserResponse},
};
use application::use_cases::feed::list_users;
use application::use_cases::profile::{get_user_by_username, update_profile};
@@ -200,6 +200,15 @@ pub async fn lookup_handler(
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,
attachment: actor
.attachment
.into_iter()
.map(|(name, value)| ProfileField { name, value })
.collect(),
}))
}
@@ -263,6 +272,7 @@ mod tests {
hasher: Arc::new(NoOpHasher),
events: store.clone(),
federation: store.clone(),
ap_repo: store.clone(),
}
}

View File

@@ -65,6 +65,10 @@ pub fn router() -> Router<AppState> {
.route("/feed", get(feed::home_feed))
.route("/feed/public", get(feed::public_feed))
.route("/search", get(feed::search_handler))
.route(
"/federation/actors/{handle}/posts",
get(federation_actors::remote_actor_posts_handler),
)
.route("/tags/popular", get(feed::get_popular_tags))
.route("/tags/{name}", get(feed::tag_thoughts_handler))
// notifications