feat(presentation): remote actor posts endpoint + extended RemoteActorResponse
This commit is contained in:
136
crates/presentation/src/handlers/federation_actors.rs
Normal file
136
crates/presentation/src/handlers/federation_actors.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user