From 069dd9af087cedc64bf5aacbab8a4e9488a542a7 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 2 Jun 2026 18:52:17 +0900 Subject: [PATCH 1/2] Add minimal ActivityPub vocab types --- Cargo.lock | 100 +++++++ Cargo.toml | 4 + crates/feder-vocab/Cargo.toml | 4 + crates/feder-vocab/src/lib.rs | 324 +++++++++++++++++++++- crates/feder-vocab/tests/phase1_shapes.rs | 142 ++++++++++ 5 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 crates/feder-vocab/tests/phase1_shapes.rs diff --git a/Cargo.lock b/Cargo.lock index 8e693f7..1fceb53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,3 +12,103 @@ dependencies = [ [[package]] name = "feder-vocab" version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 8407033..ac5a686 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,10 @@ version = "0.1.0" edition = "2024" license = "AGPL-3.0-only" +[workspace.dependencies] +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.140" + [workspace.lints.rust] warnings = "deny" diff --git a/crates/feder-vocab/Cargo.toml b/crates/feder-vocab/Cargo.toml index d0d075e..4c9957a 100644 --- a/crates/feder-vocab/Cargo.toml +++ b/crates/feder-vocab/Cargo.toml @@ -5,6 +5,10 @@ edition.workspace = true license.workspace = true [dependencies] +serde.workspace = true + +[dev-dependencies] +serde_json.workspace = true [lints] workspace = true diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index 65dbcee..c10410d 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -1 +1,323 @@ -//! Activity Vocabulary types for Feder. +//! Minimal Activity Vocabulary types for Feder. +//! +//! This crate models ActivityPub/ActivityStreams protocol data only. It does +//! not fetch remote objects, read or write storage, deliver activities, or own +//! core decision logic. + +use serde::{Deserialize, Serialize}; + +/// The canonical Activity Streams JSON-LD context URL. +pub const ACTIVITYSTREAMS_CONTEXT: &str = "https://www.w3.org/ns/activitystreams"; + +/// An absolute ActivityPub/ActivityStreams identifier. +pub type Iri = String; + +/// A non-scalar ActivityStreams property value. +/// +/// ActivityStreams object slots can contain either an embedded object or the +/// object's IRI. Phase 1 keeps both forms explicit and avoids dereferencing. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(untagged)] +pub enum Reference { + Id(Iri), + Object(Box), +} + +impl Reference { + #[must_use] + pub fn id(id: impl Into) -> Self { + Self::Id(id.into()) + } + + #[must_use] + pub fn object(object: T) -> Self { + Self::Object(Box::new(object)) + } +} + +/// A property value that can appear either once or multiple times. +/// +/// ActivityStreams commonly allows fields to be absent, scalar, or arrays. +/// Absence is represented by `Option>` on the containing type. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(untagged)] +pub enum OneOrMany { + One(T), + Many(Vec), +} + +impl OneOrMany { + #[must_use] + pub fn one(value: T) -> Self { + Self::One(value) + } + + #[must_use] + pub fn many(values: impl Into>) -> Self { + Self::Many(values.into()) + } +} + +macro_rules! activitystreams_type { + ($name:ident, $variant:ident) => { + #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] + pub enum $name { + #[default] + $variant, + } + }; +} + +activitystreams_type!(PersonType, Person); +activitystreams_type!(NoteType, Note); +activitystreams_type!(FollowType, Follow); +activitystreams_type!(AcceptType, Accept); +activitystreams_type!(CreateType, Create); + +/// A minimal ActivityPub actor for Phase 1 core tests. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Actor { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: PersonType, + pub id: Iri, + pub inbox: Iri, + pub outbox: Iri, + #[serde(rename = "preferredUsername", skip_serializing_if = "Option::is_none")] + pub preferred_username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl Actor { + #[must_use] + pub fn person(id: impl Into, inbox: impl Into, outbox: impl Into) -> Self { + Self { + context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), + kind: PersonType::default(), + id: id.into(), + inbox: inbox.into(), + outbox: outbox.into(), + preferred_username: None, + name: None, + } + } +} + +/// A minimal ActivityStreams Note object. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Note { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: NoteType, + pub id: Iri, + #[serde(rename = "attributedTo", skip_serializing_if = "Option::is_none")] + pub attributed_to: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub published: Option, +} + +impl Note { + #[must_use] + pub fn new(id: impl Into) -> Self { + Self { + context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), + kind: NoteType::default(), + id: id.into(), + attributed_to: None, + content: None, + published: None, + } + } +} + +/// A minimal Follow activity. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Follow { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: FollowType, + pub id: Iri, + pub actor: Reference, + pub object: Reference, +} + +impl Follow { + #[must_use] + pub fn new(id: impl Into, actor: Reference, object: Reference) -> Self { + Self { + context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), + kind: FollowType::default(), + id: id.into(), + actor, + object, + } + } +} + +/// A minimal Accept activity. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Accept { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: AcceptType, + pub id: Iri, + pub actor: Reference, + pub object: Reference, +} + +impl Accept { + #[must_use] + pub fn new(id: impl Into, actor: Reference, object: Reference) -> Self { + Self { + context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), + kind: AcceptType::default(), + id: id.into(), + actor, + object, + } + } +} + +/// A minimal Create activity for a concrete object type. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Create { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: CreateType, + pub id: Iri, + pub actor: Reference, + pub object: Reference, +} + +impl Create { + #[must_use] + pub fn new(id: impl Into, actor: Reference, object: Reference) -> Self { + Self { + context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), + kind: CreateType::default(), + id: id.into(), + actor, + object, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::de::DeserializeOwned; + use serde_json::json; + + fn roundtrip(value: &T) -> T + where + T: DeserializeOwned + Serialize, + { + let json = serde_json::to_string(value).expect("serialize activitystreams value"); + serde_json::from_str(&json).expect("deserialize activitystreams value") + } + + #[test] + fn actor_roundtrips_json() { + let mut actor = Actor::person( + "https://example.com/users/alice", + "https://example.com/users/alice/inbox", + "https://example.com/users/alice/outbox", + ); + actor.preferred_username = Some("alice".to_string()); + actor.name = Some("Alice".to_string()); + + assert_eq!(roundtrip(&actor), actor); + } + + #[test] + fn actor_deserializes_basic_activitypub_json() { + let actor: Actor = serde_json::from_value(json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Person", + "id": "https://example.com/users/alice", + "inbox": "https://example.com/users/alice/inbox", + "outbox": "https://example.com/users/alice/outbox", + "preferredUsername": "alice", + "name": "Alice" + })) + .expect("deserialize actor from json"); + + assert_eq!(actor.id, "https://example.com/users/alice"); + assert_eq!(actor.preferred_username, Some("alice".to_string())); + } + + #[test] + fn follow_and_accept_roundtrip_json() { + let follow = Follow::new( + "https://remote.example/activities/follow/1", + Reference::id("https://remote.example/users/bob"), + Reference::id("https://example.com/users/alice"), + ); + let accept = Accept::new( + "https://example.com/activities/accept/1", + Reference::id("https://example.com/users/alice"), + Reference::object(follow), + ); + + assert_eq!(roundtrip(&accept), accept); + } + + #[test] + fn create_note_roundtrips_json() { + let mut note = Note::new("https://example.com/notes/1"); + note.attributed_to = Some(Reference::id("https://example.com/users/alice")); + note.content = Some("Hello, fediverse.".to_string()); + note.published = Some("2026-05-29T06:30:00Z".to_string()); + + let create = Create::new( + "https://example.com/activities/create/1", + Reference::id("https://example.com/users/alice"), + Reference::object(note), + ); + + assert_eq!(roundtrip(&create), create); + } + + #[test] + fn concrete_types_reject_wrong_activitystreams_type() { + let result = serde_json::from_value::(json!({ + "type": "Accept", + "id": "https://remote.example/activities/follow/1", + "actor": "https://remote.example/users/bob", + "object": "https://example.com/users/alice" + })); + + assert!(result.is_err()); + } + + #[test] + fn one_or_many_deserializes_scalar_and_array() { + let one: OneOrMany = serde_json::from_value(json!("https://example.com/users/alice")) + .expect("deserialize scalar one-or-many value"); + let many: OneOrMany = serde_json::from_value(json!([ + "https://example.com/users/alice", + "https://example.com/users/bob" + ])) + .expect("deserialize array one-or-many value"); + + assert_eq!( + one, + OneOrMany::one("https://example.com/users/alice".to_string()) + ); + assert_eq!( + many, + OneOrMany::many([ + "https://example.com/users/alice".to_string(), + "https://example.com/users/bob".to_string() + ]) + ); + } +} diff --git a/crates/feder-vocab/tests/phase1_shapes.rs b/crates/feder-vocab/tests/phase1_shapes.rs new file mode 100644 index 0000000..ff3e51d --- /dev/null +++ b/crates/feder-vocab/tests/phase1_shapes.rs @@ -0,0 +1,142 @@ +use feder_vocab::{ACTIVITYSTREAMS_CONTEXT, Accept, Create, Follow, Iri, Note, Reference}; +use serde_json::{Value, json}; + +fn serialize(value: impl serde::Serialize) -> Value { + serde_json::to_value(value).expect("serialize vocab value") +} + +fn incoming_follow_json() -> serde_json::Value { + json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Follow", + "id": "https://remote.example/activities/follow/1", + "actor": "https://remote.example/users/bob", + "object": { + "type": "Person", + "id": "https://example.com/users/alice", + "inbox": "https://example.com/users/alice/inbox", + "outbox": "https://example.com/users/alice/outbox", + "preferredUsername": "alice" + } + }) +} + +#[test] +fn follow_activity_accepts_id_or_embedded_actor_references() { + let follow: Follow = + serde_json::from_value(incoming_follow_json()).expect("deserialize incoming follow"); + + assert_eq!(follow.id, "https://remote.example/activities/follow/1"); + assert!(matches!(follow.actor, Reference::Id(id) if id == "https://remote.example/users/bob")); + assert!( + matches!(follow.object, Reference::Object(actor) if actor.id == "https://example.com/users/alice") + ); +} + +#[test] +fn accept_activity_can_embed_follow_activity() { + let follow: Follow = + serde_json::from_value(incoming_follow_json()).expect("deserialize incoming follow"); + + let outgoing_accept = Accept::new( + "https://example.com/activities/accept/1", + Reference::id("https://example.com/users/alice"), + Reference::object(follow), + ); + + assert_eq!( + serialize(outgoing_accept), + json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Accept", + "id": "https://example.com/activities/accept/1", + "actor": "https://example.com/users/alice", + "object": { + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Follow", + "id": "https://remote.example/activities/follow/1", + "actor": "https://remote.example/users/bob", + "object": { + "type": "Person", + "id": "https://example.com/users/alice", + "inbox": "https://example.com/users/alice/inbox", + "outbox": "https://example.com/users/alice/outbox", + "preferredUsername": "alice" + } + } + }) + ); +} + +#[test] +fn local_note_can_shape_create_note_activity() { + let mut note = Note::new("https://example.com/notes/1"); + note.attributed_to = Some(Reference::id("https://example.com/users/alice")); + note.content = Some("Hello from Feder.".to_string()); + note.published = Some("2026-06-02T00:00:00Z".to_string()); + + let create = Create::new( + "https://example.com/activities/create/1", + Reference::id("https://example.com/users/alice"), + Reference::object(note), + ); + + assert_eq!( + serialize(create), + json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Create", + "id": "https://example.com/activities/create/1", + "actor": "https://example.com/users/alice", + "object": { + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Note", + "id": "https://example.com/notes/1", + "attributedTo": "https://example.com/users/alice", + "content": "Hello from Feder.", + "published": "2026-06-02T00:00:00Z" + } + }) + ); +} + +#[test] +fn reference_keeps_id_and_embedded_object_shapes_distinct() { + let id_reference: Reference = + serde_json::from_value(json!("https://example.com/notes/1")) + .expect("deserialize id reference"); + let object_reference: Reference = serde_json::from_value(json!({ + "type": "Note", + "id": "https://example.com/notes/1" + })) + .expect("deserialize embedded object reference"); + + assert!(matches!(id_reference, Reference::Id(id) if id == "https://example.com/notes/1")); + assert!( + matches!(object_reference, Reference::Object(note) if note.id == "https://example.com/notes/1") + ); +} + +#[test] +fn one_or_many_can_represent_common_recipient_shapes() { + let single: feder_vocab::OneOrMany = + serde_json::from_value(json!("https://www.w3.org/ns/activitystreams#Public")) + .expect("deserialize single recipient"); + let multiple: feder_vocab::OneOrMany = serde_json::from_value(json!([ + "https://www.w3.org/ns/activitystreams#Public", + "https://example.com/users/alice/followers" + ])) + .expect("deserialize multiple recipients"); + + assert_eq!( + single, + feder_vocab::OneOrMany::one("https://www.w3.org/ns/activitystreams#Public".to_string()) + ); + assert_eq!( + multiple, + feder_vocab::OneOrMany::many([ + "https://www.w3.org/ns/activitystreams#Public".to_string(), + "https://example.com/users/alice/followers".to_string() + ]) + ); +} From 9e177eb75120145dd4de176693f711c9ded9fee5 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 4 Jun 2026 15:43:25 +0900 Subject: [PATCH 2/2] Address vocab review feedback --- Cargo.lock | 10 ++ Cargo.toml | 3 +- crates/feder-vocab/Cargo.toml | 1 + crates/feder-vocab/src/lib.rs | 141 +++++++++++++++------- crates/feder-vocab/tests/phase1_shapes.rs | 34 +++--- 5 files changed, 132 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1fceb53..7805f2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,10 +13,20 @@ dependencies = [ name = "feder-vocab" version = "0.1.0" dependencies = [ + "iri-string", "serde", "serde_json", ] +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "serde", +] + [[package]] name = "itoa" version = "1.0.18" diff --git a/Cargo.toml b/Cargo.toml index ac5a686..c56d565 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,8 @@ edition = "2024" license = "AGPL-3.0-only" [workspace.dependencies] -serde = { version = "1.0.219", features = ["derive"] } +iri-string = { version = "0.7.12", default-features = false, features = ["alloc", "serde"] } +serde = { version = "1.0.219", default-features = false, features = ["alloc", "derive"] } serde_json = "1.0.140" [workspace.lints.rust] diff --git a/crates/feder-vocab/Cargo.toml b/crates/feder-vocab/Cargo.toml index 4c9957a..4b6a648 100644 --- a/crates/feder-vocab/Cargo.toml +++ b/crates/feder-vocab/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true license.workspace = true [dependencies] +iri-string.workspace = true serde.workspace = true [dev-dependencies] diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index c10410d..64915cf 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -1,16 +1,21 @@ //! Minimal Activity Vocabulary types for Feder. +#![no_std] //! //! This crate models ActivityPub/ActivityStreams protocol data only. It does //! not fetch remote objects, read or write storage, deliver activities, or own //! core decision logic. +extern crate alloc; + +use alloc::{boxed::Box, string::String, vec::Vec}; +use iri_string::types::IriString; use serde::{Deserialize, Serialize}; /// The canonical Activity Streams JSON-LD context URL. pub const ACTIVITYSTREAMS_CONTEXT: &str = "https://www.w3.org/ns/activitystreams"; /// An absolute ActivityPub/ActivityStreams identifier. -pub type Iri = String; +pub type Iri = IriString; /// A non-scalar ActivityStreams property value. /// @@ -25,8 +30,8 @@ pub enum Reference { impl Reference { #[must_use] - pub fn id(id: impl Into) -> Self { - Self::Id(id.into()) + pub fn id(id: Iri) -> Self { + Self::Id(id) } #[must_use] @@ -68,19 +73,28 @@ macro_rules! activitystreams_type { }; } -activitystreams_type!(PersonType, Person); activitystreams_type!(NoteType, Note); activitystreams_type!(FollowType, Follow); activitystreams_type!(AcceptType, Accept); activitystreams_type!(CreateType, Create); +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub enum ActorType { + Application, + Group, + Organization, + #[default] + Person, + Service, +} + /// A minimal ActivityPub actor for Phase 1 core tests. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct Actor { #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] pub context: Option, #[serde(rename = "type")] - pub kind: PersonType, + pub kind: ActorType, pub id: Iri, pub inbox: Iri, pub outbox: Iri, @@ -92,13 +106,22 @@ pub struct Actor { impl Actor { #[must_use] - pub fn person(id: impl Into, inbox: impl Into, outbox: impl Into) -> Self { + pub fn person(id: Iri, inbox: Iri, outbox: Iri) -> Self { + Self::new(ActorType::Person, id, inbox, outbox) + } + + #[must_use] + pub fn new(kind: ActorType, id: Iri, inbox: Iri, outbox: Iri) -> Self { Self { - context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), - kind: PersonType::default(), - id: id.into(), - inbox: inbox.into(), - outbox: outbox.into(), + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), + kind, + id, + inbox, + outbox, preferred_username: None, name: None, } @@ -123,11 +146,15 @@ pub struct Note { impl Note { #[must_use] - pub fn new(id: impl Into) -> Self { + pub fn new(id: Iri) -> Self { Self { - context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), kind: NoteType::default(), - id: id.into(), + id, attributed_to: None, content: None, published: None, @@ -149,11 +176,15 @@ pub struct Follow { impl Follow { #[must_use] - pub fn new(id: impl Into, actor: Reference, object: Reference) -> Self { + pub fn new(id: Iri, actor: Reference, object: Reference) -> Self { Self { - context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), kind: FollowType::default(), - id: id.into(), + id, actor, object, } @@ -174,11 +205,15 @@ pub struct Accept { impl Accept { #[must_use] - pub fn new(id: impl Into, actor: Reference, object: Reference) -> Self { + pub fn new(id: Iri, actor: Reference, object: Reference) -> Self { Self { - context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), kind: AcceptType::default(), - id: id.into(), + id, actor, object, } @@ -199,11 +234,15 @@ pub struct Create { impl Create { #[must_use] - pub fn new(id: impl Into, actor: Reference, object: Reference) -> Self { + pub fn new(id: Iri, actor: Reference, object: Reference) -> Self { Self { - context: Some(ACTIVITYSTREAMS_CONTEXT.to_string()), + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), kind: CreateType::default(), - id: id.into(), + id, actor, object, } @@ -213,6 +252,7 @@ impl Create { #[cfg(test)] mod tests { use super::*; + use alloc::string::ToString; use serde::de::DeserializeOwned; use serde_json::json; @@ -224,12 +264,16 @@ mod tests { serde_json::from_str(&json).expect("deserialize activitystreams value") } + fn iri(value: &str) -> Iri { + value.parse().expect("valid test IRI") + } + #[test] fn actor_roundtrips_json() { let mut actor = Actor::person( - "https://example.com/users/alice", - "https://example.com/users/alice/inbox", - "https://example.com/users/alice/outbox", + iri("https://example.com/users/alice"), + iri("https://example.com/users/alice/inbox"), + iri("https://example.com/users/alice/outbox"), ); actor.preferred_username = Some("alice".to_string()); actor.name = Some("Alice".to_string()); @@ -250,20 +294,36 @@ mod tests { })) .expect("deserialize actor from json"); - assert_eq!(actor.id, "https://example.com/users/alice"); + assert_eq!(actor.id, iri("https://example.com/users/alice")); assert_eq!(actor.preferred_username, Some("alice".to_string())); } + #[test] + fn actor_deserializes_non_person_activitypub_json() { + let actor: Actor = serde_json::from_value(json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Service", + "id": "https://example.com/actors/service", + "inbox": "https://example.com/actors/service/inbox", + "outbox": "https://example.com/actors/service/outbox", + "name": "Feder Service" + })) + .expect("deserialize service actor from json"); + + assert_eq!(actor.kind, ActorType::Service); + assert_eq!(actor.id, iri("https://example.com/actors/service")); + } + #[test] fn follow_and_accept_roundtrip_json() { let follow = Follow::new( - "https://remote.example/activities/follow/1", - Reference::id("https://remote.example/users/bob"), - Reference::id("https://example.com/users/alice"), + iri("https://remote.example/activities/follow/1"), + Reference::id(iri("https://remote.example/users/bob")), + Reference::id(iri("https://example.com/users/alice")), ); let accept = Accept::new( - "https://example.com/activities/accept/1", - Reference::id("https://example.com/users/alice"), + iri("https://example.com/activities/accept/1"), + Reference::id(iri("https://example.com/users/alice")), Reference::object(follow), ); @@ -272,14 +332,14 @@ mod tests { #[test] fn create_note_roundtrips_json() { - let mut note = Note::new("https://example.com/notes/1"); - note.attributed_to = Some(Reference::id("https://example.com/users/alice")); + let mut note = Note::new(iri("https://example.com/notes/1")); + note.attributed_to = Some(Reference::id(iri("https://example.com/users/alice"))); note.content = Some("Hello, fediverse.".to_string()); note.published = Some("2026-05-29T06:30:00Z".to_string()); let create = Create::new( - "https://example.com/activities/create/1", - Reference::id("https://example.com/users/alice"), + iri("https://example.com/activities/create/1"), + Reference::id(iri("https://example.com/users/alice")), Reference::object(note), ); @@ -308,15 +368,12 @@ mod tests { ])) .expect("deserialize array one-or-many value"); - assert_eq!( - one, - OneOrMany::one("https://example.com/users/alice".to_string()) - ); + assert_eq!(one, OneOrMany::one(iri("https://example.com/users/alice"))); assert_eq!( many, OneOrMany::many([ - "https://example.com/users/alice".to_string(), - "https://example.com/users/bob".to_string() + iri("https://example.com/users/alice"), + iri("https://example.com/users/bob") ]) ); } diff --git a/crates/feder-vocab/tests/phase1_shapes.rs b/crates/feder-vocab/tests/phase1_shapes.rs index ff3e51d..2d572d9 100644 --- a/crates/feder-vocab/tests/phase1_shapes.rs +++ b/crates/feder-vocab/tests/phase1_shapes.rs @@ -5,6 +5,10 @@ fn serialize(value: impl serde::Serialize) -> Value { serde_json::to_value(value).expect("serialize vocab value") } +fn iri(value: &str) -> Iri { + value.parse().expect("valid test IRI") +} + fn incoming_follow_json() -> serde_json::Value { json!({ "@context": ACTIVITYSTREAMS_CONTEXT, @@ -26,10 +30,12 @@ fn follow_activity_accepts_id_or_embedded_actor_references() { let follow: Follow = serde_json::from_value(incoming_follow_json()).expect("deserialize incoming follow"); - assert_eq!(follow.id, "https://remote.example/activities/follow/1"); - assert!(matches!(follow.actor, Reference::Id(id) if id == "https://remote.example/users/bob")); + assert_eq!(follow.id, iri("https://remote.example/activities/follow/1")); + assert!( + matches!(follow.actor, Reference::Id(id) if id == iri("https://remote.example/users/bob")) + ); assert!( - matches!(follow.object, Reference::Object(actor) if actor.id == "https://example.com/users/alice") + matches!(follow.object, Reference::Object(actor) if actor.id == iri("https://example.com/users/alice")) ); } @@ -39,8 +45,8 @@ fn accept_activity_can_embed_follow_activity() { serde_json::from_value(incoming_follow_json()).expect("deserialize incoming follow"); let outgoing_accept = Accept::new( - "https://example.com/activities/accept/1", - Reference::id("https://example.com/users/alice"), + iri("https://example.com/activities/accept/1"), + Reference::id(iri("https://example.com/users/alice")), Reference::object(follow), ); @@ -70,14 +76,14 @@ fn accept_activity_can_embed_follow_activity() { #[test] fn local_note_can_shape_create_note_activity() { - let mut note = Note::new("https://example.com/notes/1"); - note.attributed_to = Some(Reference::id("https://example.com/users/alice")); + let mut note = Note::new(iri("https://example.com/notes/1")); + note.attributed_to = Some(Reference::id(iri("https://example.com/users/alice"))); note.content = Some("Hello from Feder.".to_string()); note.published = Some("2026-06-02T00:00:00Z".to_string()); let create = Create::new( - "https://example.com/activities/create/1", - Reference::id("https://example.com/users/alice"), + iri("https://example.com/activities/create/1"), + Reference::id(iri("https://example.com/users/alice")), Reference::object(note), ); @@ -111,9 +117,9 @@ fn reference_keeps_id_and_embedded_object_shapes_distinct() { })) .expect("deserialize embedded object reference"); - assert!(matches!(id_reference, Reference::Id(id) if id == "https://example.com/notes/1")); + assert!(matches!(id_reference, Reference::Id(id) if id == iri("https://example.com/notes/1"))); assert!( - matches!(object_reference, Reference::Object(note) if note.id == "https://example.com/notes/1") + matches!(object_reference, Reference::Object(note) if note.id == iri("https://example.com/notes/1")) ); } @@ -130,13 +136,13 @@ fn one_or_many_can_represent_common_recipient_shapes() { assert_eq!( single, - feder_vocab::OneOrMany::one("https://www.w3.org/ns/activitystreams#Public".to_string()) + feder_vocab::OneOrMany::one(iri("https://www.w3.org/ns/activitystreams#Public")) ); assert_eq!( multiple, feder_vocab::OneOrMany::many([ - "https://www.w3.org/ns/activitystreams#Public".to_string(), - "https://example.com/users/alice/followers".to_string() + iri("https://www.w3.org/ns/activitystreams#Public"), + iri("https://example.com/users/alice/followers") ]) ); }