refactor: replace long arg lists with input/config structs and builder

- Thought::new_local → NewThought struct (7 args → 1)
- UserWriter::update_profile → UpdateProfileInput struct (6 args → 2)
- update_profile use case → UpdateProfileInput (8 args → 3)
- ActivityPubService::new → builder pattern (9 args → 5 required + 4 optional setters)
- accept_note → AcceptNoteInput struct (8 args → 1)
- ThoughtNote::new_public → ThoughtNoteInput struct (8 args → 1)

Remove all #[allow(clippy::too_many_arguments)] annotations.
This commit is contained in:
2026-05-17 12:25:53 +02:00
parent 2f5c89c381
commit d56d34cc27
31 changed files with 449 additions and 450 deletions

View File

@@ -5,6 +5,17 @@ use domain::{
value_objects::{ThoughtId, UserId, Username},
};
pub struct AcceptNoteInput<'a> {
pub ap_id: &'a str,
pub author_id: &'a UserId,
pub content: &'a str,
pub published: chrono::DateTime<chrono::Utc>,
pub sensitive: bool,
pub content_warning: Option<String>,
pub visibility: &'a str,
pub in_reply_to: Option<&'a str>,
}
/// AP-protocol endpoints for a locally-stored user (local or interned remote).
#[derive(Debug, Clone)]
pub struct ActorApUrls {
@@ -61,18 +72,8 @@ pub trait ActivityPubRepository: Send + Sync {
// ── Inbox processing (remote → local) ───────────────────────────
/// Persist an incoming remote Note. Idempotent on ap_id.
#[allow(clippy::too_many_arguments)]
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>;
async fn accept_note(&self, input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError>;
/// Apply an Update to a previously accepted remote Note.
async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError>;

View File

@@ -18,7 +18,9 @@ pub mod user;
pub mod webfinger;
pub use activitypub_federation::kinds::object::NoteType;
pub use ap_ports::{ActivityPubRepository, ActorApUrls, OutboundFederationPort, OutboxEntry};
pub use ap_ports::{
AcceptNoteInput, ActivityPubRepository, ActorApUrls, OutboundFederationPort, OutboxEntry,
};
pub use content::ApObjectHandler;
pub use data::FederationData;
pub use error::Error;

View File

@@ -163,34 +163,73 @@ pub struct ActivityPubService {
connections_repo: Arc<dyn domain::ports::RemoteActorConnectionRepository>,
}
pub struct ActivityPubServiceBuilder {
repo: Arc<dyn FederationRepository>,
user_repo: Arc<dyn ApUserRepository>,
object_handler: Arc<dyn ApObjectHandler>,
base_url: String,
connections_repo: Arc<dyn domain::ports::RemoteActorConnectionRepository>,
allow_registration: bool,
software_name: String,
debug: bool,
event_publisher: Option<Arc<dyn domain::ports::EventPublisher>>,
}
impl ActivityPubServiceBuilder {
pub fn allow_registration(mut self, v: bool) -> Self {
self.allow_registration = v;
self
}
pub fn software_name(mut self, v: impl Into<String>) -> Self {
self.software_name = v.into();
self
}
pub fn debug(mut self, v: bool) -> Self {
self.debug = v;
self
}
pub fn event_publisher(mut self, v: Arc<dyn domain::ports::EventPublisher>) -> Self {
self.event_publisher = Some(v);
self
}
pub async fn build(self) -> anyhow::Result<ActivityPubService> {
let data = FederationData::new(
self.repo,
self.user_repo,
self.object_handler,
self.base_url.clone(),
self.allow_registration,
self.software_name,
self.event_publisher,
);
let federation_config = ApFederationConfig::new(data, self.debug).await?;
Ok(ActivityPubService {
federation_config,
base_url: self.base_url,
connections_repo: self.connections_repo,
})
}
}
impl ActivityPubService {
#[allow(clippy::too_many_arguments)]
pub async fn new(
pub fn builder(
repo: Arc<dyn FederationRepository>,
user_repo: Arc<dyn ApUserRepository>,
object_handler: Arc<dyn ApObjectHandler>,
base_url: String,
allow_registration: bool,
software_name: String,
debug: bool,
event_publisher: Option<Arc<dyn domain::ports::EventPublisher>>,
base_url: impl Into<String>,
connections_repo: Arc<dyn domain::ports::RemoteActorConnectionRepository>,
) -> anyhow::Result<Self> {
let data = FederationData::new(
) -> ActivityPubServiceBuilder {
ActivityPubServiceBuilder {
repo,
user_repo,
object_handler,
base_url.clone(),
allow_registration,
software_name,
event_publisher,
);
let federation_config = ApFederationConfig::new(data, debug).await?;
Ok(Self {
federation_config,
base_url,
base_url: base_url.into(),
connections_repo,
})
allow_registration: false,
software_name: String::new(),
debug: false,
event_publisher: None,
}
}
pub fn federation_config(&self) -> &ApFederationConfig {

View File

@@ -7,9 +7,9 @@ use chrono::{DateTime, Utc};
use std::sync::Arc;
use url::Url;
use crate::note::ThoughtNote;
use crate::note::{ThoughtNote, ThoughtNoteInput};
use crate::urls::ThoughtsUrls;
use activitypub_base::{ActivityPubRepository, ApObjectHandler};
use activitypub_base::{AcceptNoteInput, ActivityPubRepository, ApObjectHandler};
use domain::ports::{EventPublisher, TagRepository};
use domain::value_objects::UserId;
@@ -58,16 +58,16 @@ impl ApObjectHandler for ThoughtsObjectHandler {
.thought
.in_reply_to_id
.map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public(
note_url.clone(),
let note = ThoughtNote::new_public(ThoughtNoteInput {
id: note_url.clone(),
actor_url,
e.thought.content.as_str().to_owned(),
e.thought.created_at,
content: e.thought.content.as_str().to_owned(),
published: e.thought.created_at,
in_reply_to,
e.thought.sensitive,
e.thought.content_warning,
followers,
);
sensitive: e.thought.sensitive,
summary: e.thought.content_warning,
followers_url: followers,
});
Ok((note_url, serde_json::to_value(&note)?))
})
.collect()
@@ -96,16 +96,16 @@ impl ApObjectHandler for ThoughtsObjectHandler {
.thought
.in_reply_to_id
.map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public(
note_url.clone(),
let note = ThoughtNote::new_public(ThoughtNoteInput {
id: note_url.clone(),
actor_url,
e.thought.content.as_str().to_owned(),
created_at,
content: e.thought.content.as_str().to_owned(),
published: created_at,
in_reply_to,
e.thought.sensitive,
e.thought.content_warning,
followers,
);
sensitive: e.thought.sensitive,
summary: e.thought.content_warning,
followers_url: followers,
});
Ok((note_url, serde_json::to_value(&note)?, created_at))
})
.collect()
@@ -143,16 +143,16 @@ impl ApObjectHandler for ThoughtsObjectHandler {
let thought_id = self
.repo
.accept_note(
ap_id.as_str(),
&author_id,
&note.content,
note.published,
note.sensitive,
note.summary,
.accept_note(AcceptNoteInput {
ap_id: ap_id.as_str(),
author_id: &author_id,
content: &note.content,
published: note.published,
sensitive: note.sensitive,
content_warning: note.summary,
visibility,
note.in_reply_to.as_ref().map(|u| u.as_str()),
)
in_reply_to: note.in_reply_to.as_ref().map(|u| u.as_str()),
})
.await
.map_err(|e| anyhow!("{e}"))?;

View File

@@ -30,30 +30,31 @@ pub struct ThoughtNote {
pub tag: Vec<serde_json::Value>,
}
pub struct ThoughtNoteInput {
pub id: Url,
pub actor_url: Url,
pub content: String,
pub published: DateTime<Utc>,
pub in_reply_to: Option<Url>,
pub sensitive: bool,
pub summary: Option<String>,
pub followers_url: Url,
}
impl ThoughtNote {
#[allow(clippy::too_many_arguments)]
pub fn new_public(
id: Url,
actor_url: Url,
content: String,
published: DateTime<Utc>,
in_reply_to: Option<Url>,
sensitive: bool,
summary: Option<String>,
followers_url: Url,
) -> Self {
pub fn new_public(p: ThoughtNoteInput) -> Self {
Self {
kind: Default::default(),
url: Some(id.clone()),
id,
attributed_to: actor_url,
content,
published,
url: Some(p.id.clone()),
id: p.id,
attributed_to: p.actor_url,
content: p.content,
published: p.published,
to: vec![AS_PUBLIC.to_string()],
cc: vec![followers_url.to_string()],
in_reply_to,
sensitive,
summary,
cc: vec![p.followers_url.to_string()],
in_reply_to: p.in_reply_to,
sensitive: p.sensitive,
summary: p.summary,
tag: Vec::new(),
}
}

View File

@@ -2,16 +2,16 @@ use super::*;
#[test]
fn note_serializes_with_public_audience() {
let note = ThoughtNote::new_public(
"https://example.com/thoughts/1".parse().unwrap(),
"https://example.com/users/alice".parse().unwrap(),
"Hello world".to_string(),
chrono::Utc::now(),
None,
false,
None,
"https://example.com/users/alice/followers".parse().unwrap(),
);
let note = ThoughtNote::new_public(super::ThoughtNoteInput {
id: "https://example.com/thoughts/1".parse().unwrap(),
actor_url: "https://example.com/users/alice".parse().unwrap(),
content: "Hello world".to_string(),
published: chrono::Utc::now(),
in_reply_to: None,
sensitive: false,
summary: None,
followers_url: "https://example.com/users/alice/followers".parse().unwrap(),
});
let json = serde_json::to_string(&note).unwrap();
assert!(json.contains(AS_PUBLIC));
assert!(json.contains("Hello world"));

View File

@@ -1,7 +1,7 @@
use super::*;
use domain::{
models::{
thought::{Thought, Visibility},
thought::{NewThought, Thought, Visibility},
user::User,
},
ports::{SearchPort, ThoughtRepository, UserWriter},
@@ -19,15 +19,15 @@ async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (Us
PasswordHash("h".into()),
);
urepo.save(&u).await.unwrap();
let t = Thought::new_local(
ThoughtId::new(),
u.id.clone(),
Content::new_local(content).unwrap(),
None,
Visibility::Public,
None,
false,
);
let t = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: u.id.clone(),
content: Content::new_local(content).unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
trepo.save(&t).await.unwrap();
(u, t)
}

View File

@@ -6,7 +6,7 @@ const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry};
use activitypub_base::{AcceptNoteInput, ActivityPubRepository, ActorApUrls, OutboxEntry};
use domain::{
errors::DomainError,
models::thought::{Thought, Visibility},
@@ -210,17 +210,17 @@ impl ActivityPubRepository for PgActivityPubRepository {
.map(|_| ())
}
async fn accept_note(
&self,
ap_id: &str,
author_id: &UserId,
content: &str,
published: DateTime<Utc>,
sensitive: bool,
content_warning: Option<String>,
visibility: &str,
in_reply_to: Option<&str>,
) -> Result<ThoughtId, DomainError> {
async fn accept_note(&self, input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError> {
let AcceptNoteInput {
ap_id,
author_id,
content,
published,
sensitive,
content_warning,
visibility,
in_reply_to,
} = input;
let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
let (in_reply_to_id, in_reply_to_url) = match in_reply_to {
Some(url) => {

View File

@@ -1,5 +1,5 @@
use super::*;
use activitypub_base::ActivityPubRepository;
use activitypub_base::{AcceptNoteInput, ActivityPubRepository};
#[sqlx::test(migrations = "./migrations")]
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
@@ -16,16 +16,16 @@ async fn accept_and_retract_note(pool: sqlx::PgPool) {
let actor_url = "https://remote.example/users/bob";
let ap_id = "https://remote.example/notes/1";
let author = repo.intern_remote_actor(actor_url).await.unwrap();
repo.accept_note(
repo.accept_note(AcceptNoteInput {
ap_id,
&author,
"hello from remote",
chrono::Utc::now(),
false,
None,
"public",
None,
)
author_id: &author,
content: "hello from remote",
published: chrono::Utc::now(),
sensitive: false,
content_warning: None,
visibility: "public",
in_reply_to: None,
})
.await
.unwrap();
repo.retract_note(ap_id).await.unwrap();
@@ -46,16 +46,16 @@ async fn accept_note_returns_thought_id(pool: sqlx::PgPool) {
.unwrap();
let thought_id = repo
.accept_note(
"https://remote.example/notes/1",
&actor_user_id,
"Hello #rust world",
chrono::Utc::now(),
false,
None,
"public",
None,
)
.accept_note(AcceptNoteInput {
ap_id: "https://remote.example/notes/1",
author_id: &actor_user_id,
content: "Hello #rust world",
published: chrono::Utc::now(),
sensitive: false,
content_warning: None,
visibility: "public",
in_reply_to: None,
})
.await
.unwrap();

View File

@@ -3,7 +3,7 @@ use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::{
models::{
feed::PageParams,
thought::{Thought, Visibility},
thought::{NewThought, Thought, Visibility},
user::User,
},
ports::{FeedQuery, ThoughtRepository, UserWriter},
@@ -20,15 +20,15 @@ async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thou
PasswordHash("h".into()),
);
urepo.save(&u).await.unwrap();
let t = Thought::new_local(
ThoughtId::new(),
u.id.clone(),
Content::new_local(content).unwrap(),
None,
Visibility::Public,
None,
false,
);
let t = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: u.id.clone(),
content: Content::new_local(content).unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
trepo.save(&t).await.unwrap();
(u, t)
}

View File

@@ -3,7 +3,7 @@ use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::ports::{ThoughtRepository, UserWriter};
use domain::{
models::{
thought::{Thought, Visibility},
thought::{NewThought, Thought, Visibility},
user::User,
},
value_objects::*,
@@ -29,15 +29,15 @@ async fn attach_and_list(pool: sqlx::PgPool) {
PasswordHash("h".into()),
);
urepo.save(&u).await.unwrap();
let t = Thought::new_local(
ThoughtId::new(),
u.id.clone(),
Content::new_local("hi").unwrap(),
None,
Visibility::Public,
None,
false,
);
let t = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: u.id.clone(),
content: Content::new_local("hi").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
trepo.save(&t).await.unwrap();
let repo = PgTagRepository::new(pool);
let tag = repo.find_or_create("greetings").await.unwrap();

View File

@@ -1,7 +1,7 @@
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::{
models::{
thought::{Thought, Visibility},
thought::{NewThought, Thought, Visibility},
user::User,
},
ports::{ThoughtRepository, UserWriter},
@@ -23,15 +23,15 @@ pub async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User
pub async fn seed_user_and_thought(pool: &sqlx::PgPool) -> (User, Thought) {
let user = seed_user(pool, "alice", "alice@ex.com").await;
let trepo = PgThoughtRepository::new(pool.clone());
let t = Thought::new_local(
ThoughtId::new(),
user.id.clone(),
Content::new_local("hi").unwrap(),
None,
Visibility::Public,
None,
false,
);
let t = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: user.id.clone(),
content: Content::new_local("hi").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
trepo.save(&t).await.unwrap();
(user, t)
}

View File

@@ -1,7 +1,7 @@
use super::*;
use crate::test_helpers::seed_user;
use domain::{
models::thought::{Thought, Visibility},
models::thought::{NewThought, Thought, Visibility},
value_objects::*,
};
@@ -9,15 +9,15 @@ use domain::{
async fn save_and_find_thought(pool: sqlx::PgPool) {
let user = seed_user(&pool, "alice", "alice@ex.com").await;
let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local(
ThoughtId::new(),
user.id.clone(),
Content::new_local("hello world").unwrap(),
None,
Visibility::Public,
None,
false,
);
let t = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: user.id.clone(),
content: Content::new_local("hello world").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
repo.save(&t).await.unwrap();
let found = repo.find_by_id(&t.id).await.unwrap().unwrap();
assert_eq!(found.content.as_str(), "hello world");
@@ -28,15 +28,15 @@ async fn save_and_find_thought(pool: sqlx::PgPool) {
async fn delete_thought(pool: sqlx::PgPool) {
let user = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local(
ThoughtId::new(),
user.id.clone(),
Content::new_local("bye").unwrap(),
None,
Visibility::Public,
None,
false,
);
let t = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: user.id.clone(),
content: Content::new_local("bye").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
repo.save(&t).await.unwrap();
repo.delete(&t.id, &user.id).await.unwrap();
assert!(repo.find_by_id(&t.id).await.unwrap().is_none());
@@ -47,15 +47,15 @@ async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local(
ThoughtId::new(),
alice.id.clone(),
Content::new_local("secret").unwrap(),
None,
Visibility::Public,
None,
false,
);
let t = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: alice.id.clone(),
content: Content::new_local("secret").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
repo.save(&t).await.unwrap();
let err = repo.delete(&t.id, &bob.id).await.unwrap_err();
assert!(matches!(err, DomainError::NotFound));
@@ -65,24 +65,24 @@ async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) {
async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
let user = seed_user(&pool, "charlie", "charlie@ex.com").await;
let repo = PgThoughtRepository::new(pool);
let root = Thought::new_local(
ThoughtId::new(),
user.id.clone(),
Content::new_local("root").unwrap(),
None,
Visibility::Public,
None,
false,
);
let reply = Thought::new_local(
ThoughtId::new(),
user.id.clone(),
Content::new_local("reply").unwrap(),
Some(root.id.clone()),
Visibility::Public,
None,
false,
);
let root = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: user.id.clone(),
content: Content::new_local("root").unwrap(),
in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
let reply = Thought::new_local(NewThought {
id: ThoughtId::new(),
user_id: user.id.clone(),
content: Content::new_local("reply").unwrap(),
in_reply_to_id: Some(root.id.clone()),
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
});
repo.save(&root).await.unwrap();
repo.save(&reply).await.unwrap();
let thread = repo.get_thread(&root.id).await.unwrap();

View File

@@ -4,7 +4,7 @@ use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::feed::{PageParams, Paginated, UserSummary},
models::user::User,
models::user::{UpdateProfileInput, User},
ports::{UserReader, UserWriter},
value_objects::{Email, PasswordHash, UserId, Username},
};
@@ -265,21 +265,17 @@ impl UserWriter for PgUserRepository {
async fn update_profile(
&self,
user_id: &UserId,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
input: UpdateProfileInput,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE users SET display_name=$2,bio=$3,avatar_url=$4,header_url=$5,custom_css=$6,updated_at=NOW() WHERE id=$1"
)
.bind(user_id.as_uuid())
.bind(display_name)
.bind(bio)
.bind(avatar_url)
.bind(header_url)
.bind(custom_css)
.bind(input.display_name)
.bind(input.bio)
.bind(input.avatar_url)
.bind(input.header_url)
.bind(input.custom_css)
.execute(&self.pool)
.await
.into_domain()

View File

@@ -1,5 +1,8 @@
use super::*;
use domain::{models::user::User, value_objects::*};
use domain::{
models::user::{UpdateProfileInput, User},
value_objects::*,
};
#[sqlx::test(migrations = "./migrations")]
async fn save_and_find_by_id(pool: sqlx::PgPool) {
@@ -55,11 +58,11 @@ async fn update_profile_changes_fields(pool: sqlx::PgPool) {
repo.save(&user).await.unwrap();
repo.update_profile(
&user.id,
Some("Charlie".into()),
Some("bio".into()),
None,
None,
None,
UpdateProfileInput {
display_name: Some("Charlie".into()),
bio: Some("bio".into()),
..Default::default()
},
)
.await
.unwrap();