From 31487882e0af3438240fcd00039a1f28a11d4e4c Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 14 May 2026 20:06:55 +0200 Subject: [PATCH] feat(presentation): /federation/lookup and /federation/follow endpoints --- crates/api-types/src/requests.rs | 6 + crates/api-types/src/responses.rs | 9 ++ .../presentation/src/handlers/federation.rs | 130 ++++++++++++++++++ crates/presentation/src/handlers/mod.rs | 1 + crates/presentation/src/routes.rs | 8 +- 5 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 crates/presentation/src/handlers/federation.rs diff --git a/crates/api-types/src/requests.rs b/crates/api-types/src/requests.rs index 5f9c0d9..34e536d 100644 --- a/crates/api-types/src/requests.rs +++ b/crates/api-types/src/requests.rs @@ -80,3 +80,9 @@ pub struct SearchQuery { pub page: Option, pub per_page: Option, } + +#[derive(serde::Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct FollowRemoteRequest { + pub handle: String, +} diff --git a/crates/api-types/src/responses.rs b/crates/api-types/src/responses.rs index 6e69a14..d3d84f1 100644 --- a/crates/api-types/src/responses.rs +++ b/crates/api-types/src/responses.rs @@ -87,3 +87,12 @@ pub struct CreatedApiKeyResponse { /// Raw API key — shown only once at creation pub key: String, } + +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RemoteActorResponse { + pub handle: String, + pub display_name: Option, + pub avatar_url: Option, + pub url: String, +} diff --git a/crates/presentation/src/handlers/federation.rs b/crates/presentation/src/handlers/federation.rs new file mode 100644 index 0000000..0e1504d --- /dev/null +++ b/crates/presentation/src/handlers/federation.rs @@ -0,0 +1,130 @@ +use axum::{ + extract::{Query, State}, + http::StatusCode, + Json, +}; +use serde::Deserialize; + +use api_types::{requests::FollowRemoteRequest, responses::RemoteActorResponse}; + +use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; + +#[derive(Deserialize)] +pub struct LookupQuery { + pub handle: String, +} + +pub async fn lookup_handler( + State(s): State, + Query(q): Query, +) -> Result, ApiError> { + let actor = s.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, + })) +} + +pub async fn follow_remote_handler( + State(s): State, + AuthUser(uid): AuthUser, + Json(body): Json, +) -> Result { + s.federation.follow_remote(&uid, &body.handle).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use axum::{ + body::Body, + http::{Request, StatusCode}, + routing::{get, post}, + 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 { + Err(DomainError::Internal("noop".into())) + } + fn validate_token(&self, _token: &str) -> Result { + Err(DomainError::Unauthorized) + } + } + + struct NoOpHasher; + #[async_trait] + impl PasswordHasher for NoOpHasher { + async fn hash(&self, _plain: &str) -> Result { + Err(DomainError::Internal("noop".into())) + } + async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result { + Ok(false) + } + } + + fn make_state() -> crate::state::AppState { + let store = Arc::new(TestStore::default()); + crate::state::AppState { + users: store.clone(), + thoughts: store.clone(), + likes: store.clone(), + boosts: store.clone(), + follows: store.clone(), + blocks: store.clone(), + tags: store.clone(), + api_keys: store.clone(), + top_friends: store.clone(), + notifications: store.clone(), + remote_actors: store.clone(), + feed: store.clone(), + search: store.clone(), + auth: Arc::new(NoOpAuth), + hasher: Arc::new(NoOpHasher), + events: store.clone(), + federation: store.clone(), + } + } + + fn app() -> Router { + Router::new() + .route("/federation/lookup", get(lookup_handler)) + .route("/federation/follow", post(follow_remote_handler)) + .with_state(make_state()) + } + + #[tokio::test] + async fn lookup_unknown_handle_returns_404() { + let req = Request::builder() + .uri("/federation/lookup?handle=%40alice%40example.com") + .body(Body::empty()) + .unwrap(); + let resp = app().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn follow_remote_without_auth_returns_401() { + let req = Request::builder() + .method("POST") + .uri("/federation/follow") + .header("content-type", "application/json") + .body(Body::from(r#"{"handle":"@alice@example.com"}"#)) + .unwrap(); + let resp = app().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/crates/presentation/src/handlers/mod.rs b/crates/presentation/src/handlers/mod.rs index 82c8aca..325aa86 100644 --- a/crates/presentation/src/handlers/mod.rs +++ b/crates/presentation/src/handlers/mod.rs @@ -1,5 +1,6 @@ pub mod api_keys; pub mod auth; +pub mod federation; pub mod feed; pub mod health; pub mod notifications; diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index bdd2745..a1f85b6 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -92,7 +92,13 @@ pub fn router() -> Router { "/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)); + .route("/api-keys/{id}", delete(api_keys::delete_api_key_handler)) + // federation + .route("/federation/lookup", get(federation::lookup_handler)) + .route( + "/federation/follow", + post(federation::follow_remote_handler), + ); openapi::serve(api_routes) }