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() + ]) + ); +}