use std::collections::BTreeMap;
use chrono::{DateTime, FixedOffset};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::actor::{Endpoints, PublicKey};
use crate::kind;
use crate::multikey::AssertionMethod;
use crate::proof::Proof;
use crate::value::{HasId, OneOrMany, Public, UrlOr};
pub type ObjectRef = UrlOr<Box<Object>>;
pub type LanguageMap = BTreeMap<String, String>;
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(
clippy::struct_field_names,
reason = "the `object`, `relationship`, `subject` etc. field names are all mandated verbatim by the Activity Streams 2.0 vocabulary and cannot be renamed without breaking interoperability"
)]
pub struct Object {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<Url>,
#[serde(rename = "type", default, skip_serializing_if = "OneOrMany::is_empty")]
pub kind: OneOrMany<String>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub attachment: OneOrMany<ObjectRef>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub attributed_to: OneOrMany<ObjectRef>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub audience: OneOrMany<ObjectRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_map: Option<LanguageMap>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub context: OneOrMany<ObjectRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name_map: Option<LanguageMap>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_time: Option<DateTime<FixedOffset>>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub generator: OneOrMany<ObjectRef>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub icon: OneOrMany<ObjectRef>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub image: OneOrMany<ObjectRef>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub in_reply_to: OneOrMany<ObjectRef>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub location: OneOrMany<ObjectRef>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub preview: OneOrMany<ObjectRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub published: Option<DateTime<FixedOffset>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub replies: Option<Box<ObjectRef>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_time: Option<DateTime<FixedOffset>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary_map: Option<LanguageMap>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub tag: OneOrMany<ObjectRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated: Option<DateTime<FixedOffset>>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub url: OneOrMany<ObjectRef>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub to: OneOrMany<ObjectRef>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub bto: OneOrMany<ObjectRef>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub cc: OneOrMany<ObjectRef>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub bcc: OneOrMany<ObjectRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub media_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<String>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub actor: OneOrMany<ObjectRef>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub object: OneOrMany<ObjectRef>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub target: OneOrMany<ObjectRef>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub result: OneOrMany<ObjectRef>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub origin: OneOrMany<ObjectRef>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub instrument: OneOrMany<ObjectRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_items: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current: Option<Box<ObjectRef>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first: Option<Box<ObjectRef>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last: Option<Box<ObjectRef>>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub items: OneOrMany<ObjectRef>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub ordered_items: OneOrMany<ObjectRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub part_of: Option<Box<ObjectRef>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next: Option<Box<ObjectRef>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prev: Option<Box<ObjectRef>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_index: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub accuracy: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub altitude: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latitude: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub longitude: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub radius: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub units: Option<String>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub one_of: OneOrMany<ObjectRef>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub any_of: OneOrMany<ObjectRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub closed: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub former_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted: Option<DateTime<FixedOffset>>,
#[serde(rename = "subject", default, skip_serializing_if = "Option::is_none")]
pub relationship_subject: Option<Box<ObjectRef>>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub relationship: OneOrMany<ObjectRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub describes: Option<Box<ObjectRef>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inbox: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub outbox: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub followers: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub following: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub liked: Option<Url>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub streams: Vec<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_key: Option<PublicKey>,
#[serde(skip_serializing_if = "Option::is_none")]
pub endpoints: Option<Endpoints>,
#[serde(skip_serializing_if = "Option::is_none")]
pub featured: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub featured_tags: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manually_approves_followers: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub discoverable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub indexable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub memorial: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assertion_method: Vec<AssertionMethod>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub authentication: Vec<AssertionMethod>,
#[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
pub proof: OneOrMany<Proof>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
impl Object {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_kind(kind: impl Into<String>) -> Self {
Self {
kind: OneOrMany::one(kind.into()),
..Self::default()
}
}
#[must_use]
pub fn with_id(mut self, id: Url) -> Self {
self.id = Some(id);
self
}
#[must_use]
pub fn is_kind(&self, kind: &str) -> bool {
self.kind.iter().any(|k| k == kind)
}
#[must_use]
pub fn primary_kind(&self) -> Option<&str> {
self.kind.first().map(String::as_str)
}
#[must_use]
pub fn is_actor(&self) -> bool {
self.is_kind(kind::actor::PERSON)
|| self.is_kind(kind::actor::GROUP)
|| self.is_kind(kind::actor::ORGANIZATION)
|| self.is_kind(kind::actor::APPLICATION)
|| self.is_kind(kind::actor::SERVICE)
}
#[must_use]
pub fn is_collection(&self) -> bool {
self.is_kind(kind::core::COLLECTION)
|| self.is_kind(kind::core::ORDERED_COLLECTION)
|| self.is_kind(kind::core::COLLECTION_PAGE)
|| self.is_kind(kind::core::ORDERED_COLLECTION_PAGE)
}
#[must_use]
pub fn is_public(&self) -> bool {
fn any_public(refs: &OneOrMany<ObjectRef>) -> bool {
refs.iter().any(|r| match r {
UrlOr::Url(u) => Public::is_public(u.as_str()),
UrlOr::Object(o) => o.id.as_ref().is_some_and(|u| Public::is_public(u.as_str())),
})
}
any_public(&self.to) || any_public(&self.cc) || any_public(&self.audience)
}
}
impl HasId for Object {
fn id(&self) -> Option<&Url> {
self.id.as_ref()
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use serde_json::json;
use super::*;
#[test]
fn empty_object_roundtrips_as_empty_json() {
let obj = Object::new();
let v = serde_json::to_value(&obj).unwrap();
assert_eq!(v, json!({}));
}
#[test]
fn with_kind_emits_type() {
let obj = Object::with_kind("Note");
let v = serde_json::to_value(&obj).unwrap();
assert_eq!(v, json!({ "type": "Note" }));
}
#[test]
fn kind_helpers_work() {
let note = Object::with_kind("Note");
assert!(note.is_kind("Note"));
assert_eq!(note.primary_kind(), Some("Note"));
assert!(!note.is_actor());
assert!(!note.is_collection());
}
#[test]
fn actor_detection_covers_all_standard_types() {
for t in [
kind::actor::PERSON,
kind::actor::GROUP,
kind::actor::ORGANIZATION,
kind::actor::APPLICATION,
kind::actor::SERVICE,
] {
let a = Object::with_kind(t);
assert!(a.is_actor(), "{t} should be an actor");
}
}
#[test]
fn is_public_detects_bare_url_in_to() {
let mut obj = Object::with_kind("Note");
obj.to = OneOrMany::one(UrlOr::Url(
Url::parse(Public::URI).expect("Public::URI must parse"),
));
assert!(obj.is_public());
}
#[test]
fn is_public_detects_inlined_object_in_cc() {
let mut obj = Object::with_kind("Note");
let public_obj =
Object::new().with_id(Url::parse(Public::URI).expect("Public::URI must parse"));
obj.cc = OneOrMany::one(UrlOr::Object(Box::new(public_obj)));
assert!(obj.is_public());
}
#[test]
fn is_public_detects_target_in_audience() {
let mut obj = Object::with_kind("Note");
obj.audience = OneOrMany::one(UrlOr::Url(
Url::parse(Public::URI).expect("Public::URI must parse"),
));
assert!(obj.is_public());
}
#[test]
fn is_public_ignores_bto_and_bcc() {
let mut obj = Object::with_kind("Note");
obj.bto = OneOrMany::one(UrlOr::Url(Url::parse(Public::URI).unwrap()));
assert!(!obj.is_public(), "bto must not be considered public");
let mut obj2 = Object::with_kind("Note");
obj2.bcc = OneOrMany::one(UrlOr::Url(Url::parse(Public::URI).unwrap()));
assert!(!obj2.is_public(), "bcc must not be considered public");
}
#[test]
fn place_properties_roundtrip() {
let raw = json!({
"type": "Place",
"name": "Work Office",
"latitude": 36.75,
"longitude": 119.7726,
"altitude": 90.0,
"accuracy": 94.5,
"radius": 10.5,
"units": "m"
});
let obj: Object = serde_json::from_value(raw.clone()).unwrap();
assert_eq!(obj.latitude, Some(36.75));
assert_eq!(obj.longitude, Some(119.7726));
assert_eq!(obj.altitude, Some(90.0));
assert_eq!(obj.accuracy, Some(94.5));
assert_eq!(obj.radius, Some(10.5));
assert_eq!(obj.units.as_deref(), Some("m"));
let back = serde_json::to_value(&obj).unwrap();
assert_eq!(back, raw);
}
#[test]
fn question_properties_roundtrip() {
let raw = json!({
"type": "Question",
"name": "What is your favourite colour?",
"oneOf": [
{ "type": "Note", "name": "Red" },
{ "type": "Note", "name": "Blue" }
],
"closed": "2026-01-01T00:00:00Z"
});
let obj: Object = serde_json::from_value(raw.clone()).unwrap();
assert_eq!(obj.one_of.len(), 2);
assert!(obj.closed.is_some());
let back = serde_json::to_value(&obj).unwrap();
assert_eq!(back, raw);
}
#[test]
fn tombstone_properties_roundtrip() {
let raw = json!({
"id": "https://mastodon.social/users/alice/statuses/1",
"type": "Tombstone",
"formerType": "Note",
"deleted": "2026-04-20T12:00:00Z"
});
let obj: Object = serde_json::from_value(raw.clone()).unwrap();
assert!(obj.is_kind("Tombstone"));
assert_eq!(obj.former_type.as_deref(), Some("Note"));
assert!(obj.deleted.is_some());
let back = serde_json::to_value(&obj).unwrap();
assert_eq!(back, raw);
}
#[test]
fn relationship_properties_roundtrip() {
let raw = json!({
"type": "Relationship",
"subject": "https://example.com/users/alice",
"relationship": "http://purl.org/vocab/relationship/acquaintanceOf",
"object": "https://example.com/users/bob"
});
let obj: Object = serde_json::from_value(raw.clone()).unwrap();
assert!(obj.relationship_subject.is_some());
assert_eq!(obj.relationship.len(), 1);
assert_eq!(obj.object.len(), 1);
let back = serde_json::to_value(&obj).unwrap();
assert_eq!(back, raw);
}
#[test]
fn profile_describes_roundtrip() {
let raw = json!({
"type": "Profile",
"describes": {
"type": "Person",
"name": "Alice"
}
});
let obj: Object = serde_json::from_value(raw.clone()).unwrap();
assert!(obj.describes.is_some());
let back = serde_json::to_value(&obj).unwrap();
assert_eq!(back, raw);
}
#[test]
fn mastodon_note_roundtrips() {
let raw = json!({
"id": "https://mastodon.social/users/alice/statuses/1",
"type": "Note",
"attributedTo": "https://mastodon.social/users/alice",
"content": "<p>Hello, Fediverse</p>",
"published": "2026-04-20T10:00:00+00:00",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://mastodon.social/users/alice/followers"],
"sensitive": false,
"inReplyTo": null
});
let obj: Object = serde_json::from_value(raw).unwrap();
assert!(obj.is_kind("Note"));
assert_eq!(obj.content.as_deref(), Some("<p>Hello, Fediverse</p>"));
assert!(obj.is_public());
assert_eq!(obj.attributed_to.len(), 1);
assert!(obj.extra.contains_key("sensitive"));
}
#[test]
fn extension_fields_roundtrip() {
let raw = json!({
"type": "Note",
"_misskey_quote": "https://misskey.example/note/abc",
"blurhash": "LEHV6nWB2yk8pyo0adR*.7kCMdnj"
});
let obj: Object = serde_json::from_value(raw.clone()).unwrap();
assert_eq!(obj.extra.len(), 2);
let back = serde_json::to_value(&obj).unwrap();
assert_eq!(back, raw);
}
}