Skip to main content

actpub_activitystreams/
object.rs

1//! The universal Activity Streams 2.0 [`Object`] container.
2//!
3//! AS 2.0 defines a rich vocabulary of object types (Actor, Activity,
4//! Collection, Note, …) that share the majority of their properties.
5//! Rather than materialize every specialisation as an independent Rust
6//! struct, this crate models all of them through a single [`Object`] type
7//! with every standard property represented directly as a typed field.
8//! The [`kind`](crate::kind) module provides string constants for
9//! distinguishing variants, and [`Object::is_kind`] gives an ergonomic
10//! check against them.
11//!
12//! This mirrors the design of the popular `activitystreams` Rust crate and
13//! of reference implementations such as `activitypub-federation-rust`; it
14//! is the most interoperable style for a Fediverse where many
15//! implementations emit structurally ambiguous JSON-LD.
16
17use std::collections::BTreeMap;
18
19use chrono::{DateTime, FixedOffset};
20use serde::{Deserialize, Serialize};
21use url::Url;
22
23use crate::actor::{Endpoints, PublicKey};
24use crate::kind;
25use crate::multikey::AssertionMethod;
26use crate::proof::Proof;
27use crate::value::{HasId, OneOrMany, Public, UrlOr};
28
29/// A reference-valued property that may appear as a bare URL or as an
30/// inlined [`Object`].
31///
32/// [`Object`] is recursive in its own properties, so the inline arm is
33/// boxed to keep the struct size predictable.
34pub type ObjectRef = UrlOr<Box<Object>>;
35
36/// A language map keyed by BCP-47 language tag, as used by `contentMap`,
37/// `summaryMap`, and `nameMap`.
38pub type LanguageMap = BTreeMap<String, String>;
39
40/// The universal Activity Streams 2.0 object container.
41///
42/// Every specification-defined property across [Object][obj],
43/// [Activity][act], [Collection][coll], and [CollectionPage][page] is
44/// represented as a typed field. Properties that are absent on the wire
45/// are deserialised as `None` (for scalar fields) or an empty
46/// [`OneOrMany`] (for array fields). Unknown properties are preserved
47/// verbatim in [`extra`](Self::extra), ensuring lossless round-trips
48/// across implementations that emit non-standard extensions.
49///
50/// [obj]: https://www.w3.org/TR/activitystreams-core/#object
51/// [act]: https://www.w3.org/TR/activitystreams-core/#activities
52/// [coll]: https://www.w3.org/TR/activitystreams-core/#collections
53/// [page]: https://www.w3.org/TR/activitystreams-core/#dfn-collectionpage
54#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56#[allow(
57    clippy::struct_field_names,
58    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"
59)]
60pub struct Object {
61    /// Globally unique identifier of this object.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub id: Option<Url>,
64
65    /// Type(s) of this object. Multiple types are permitted; most
66    /// Fediverse implementations emit exactly one.
67    #[serde(rename = "type", default, skip_serializing_if = "OneOrMany::is_empty")]
68    pub kind: OneOrMany<String>,
69
70    /// Files or media objects attached to this object.
71    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
72    pub attachment: OneOrMany<ObjectRef>,
73
74    /// The actors attributed as creators of this object.
75    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
76    pub attributed_to: OneOrMany<ObjectRef>,
77
78    /// Intended audience for this object.
79    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
80    pub audience: OneOrMany<ObjectRef>,
81
82    /// Plain-text or HTML content of this object.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub content: Option<String>,
85
86    /// Localised content variants keyed by BCP-47 language tag.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub content_map: Option<LanguageMap>,
89
90    /// AS 2.0 application-level `context` property.
91    ///
92    /// Note: this is *not* the JSON-LD `@context` — that is handled by
93    /// [`WithContext`](crate::WithContext).
94    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
95    pub context: OneOrMany<ObjectRef>,
96
97    /// Plain-text display name.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub name: Option<String>,
100
101    /// Localised display names keyed by BCP-47 language tag.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub name_map: Option<LanguageMap>,
104
105    /// End time for an interval-valued object (`xsd:dateTime`).
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub end_time: Option<DateTime<FixedOffset>>,
108
109    /// Entity that generated this object.
110    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
111    pub generator: OneOrMany<ObjectRef>,
112
113    /// Small iconic representation of this object.
114    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
115    pub icon: OneOrMany<ObjectRef>,
116
117    /// Primary image associated with this object.
118    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
119    pub image: OneOrMany<ObjectRef>,
120
121    /// Objects this object is in reply to.
122    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
123    pub in_reply_to: OneOrMany<ObjectRef>,
124
125    /// Associated physical or virtual location.
126    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
127    pub location: OneOrMany<ObjectRef>,
128
129    /// Preview resource associated with this object.
130    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
131    pub preview: OneOrMany<ObjectRef>,
132
133    /// Publication timestamp (`xsd:dateTime`).
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub published: Option<DateTime<FixedOffset>>,
136
137    /// Collection of replies to this object.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub replies: Option<Box<ObjectRef>>,
140
141    /// Start time for an interval-valued object (`xsd:dateTime`).
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub start_time: Option<DateTime<FixedOffset>>,
144
145    /// Plain-text or HTML summary of this object.
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub summary: Option<String>,
148
149    /// Localised summary variants keyed by BCP-47 language tag.
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub summary_map: Option<LanguageMap>,
152
153    /// `ActivityPub` §3.3 `source` property carrying the original
154    /// representation of the content before transformation to
155    /// [`content`](Self::content). Commonly set to
156    /// `{"content": "...", "mediaType": "text/markdown"}` by Lemmy,
157    /// `PeerTube` and other Fediverse implementations that need a
158    /// lossless edit channel distinct from the rendered HTML.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub source: Option<Box<ObjectRef>>,
161
162    /// Activity Streams 2.0 extension `as:sensitive`: marks content
163    /// as requiring a content warning before display. Mastodon uses
164    /// this to gate media behind a "show more" control; flipping it
165    /// on objects without media still propagates the cw flag to
166    /// compatible clients.
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub sensitive: Option<bool>,
169
170    /// Tags (mentions, hashtags, emoji) linked to this object.
171    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
172    pub tag: OneOrMany<ObjectRef>,
173
174    /// Last-updated timestamp (`xsd:dateTime`).
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub updated: Option<DateTime<FixedOffset>>,
177
178    /// URL(s) providing alternate representations of this object.
179    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
180    pub url: OneOrMany<ObjectRef>,
181
182    /// Public primary recipients.
183    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
184    pub to: OneOrMany<ObjectRef>,
185
186    /// Private primary recipients (stripped before delivery).
187    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
188    pub bto: OneOrMany<ObjectRef>,
189
190    /// Public secondary recipients.
191    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
192    pub cc: OneOrMany<ObjectRef>,
193
194    /// Private secondary recipients (stripped before delivery).
195    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
196    pub bcc: OneOrMany<ObjectRef>,
197
198    /// MIME type of this object's payload.
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub media_type: Option<String>,
201
202    /// `xsd:duration` lexical form (e.g. `"PT5M"`).
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub duration: Option<String>,
205
206    /// One or more actors performing the activity.
207    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
208    pub actor: OneOrMany<ObjectRef>,
209
210    /// Object of the activity. Omitted for `IntransitiveActivity`.
211    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
212    pub object: OneOrMany<ObjectRef>,
213
214    /// Indirect target of the activity.
215    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
216    pub target: OneOrMany<ObjectRef>,
217
218    /// Result of the activity.
219    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
220    pub result: OneOrMany<ObjectRef>,
221
222    /// Origin from which the activity proceeds.
223    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
224    pub origin: OneOrMany<ObjectRef>,
225
226    /// Instrument used to perform the activity.
227    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
228    pub instrument: OneOrMany<ObjectRef>,
229
230    /// Number of items in the collection.
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub total_items: Option<u64>,
233
234    /// Current page of a paged collection.
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub current: Option<Box<ObjectRef>>,
237
238    /// First page of a paged collection.
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub first: Option<Box<ObjectRef>>,
241
242    /// Last page of a paged collection.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub last: Option<Box<ObjectRef>>,
245
246    /// Items in an unordered collection.
247    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
248    pub items: OneOrMany<ObjectRef>,
249
250    /// Items in an ordered collection.
251    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
252    pub ordered_items: OneOrMany<ObjectRef>,
253
254    /// Collection this page is part of.
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub part_of: Option<Box<ObjectRef>>,
257
258    /// Next page.
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub next: Option<Box<ObjectRef>>,
261
262    /// Previous page.
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub prev: Option<Box<ObjectRef>>,
265
266    /// Starting index (`OrderedCollectionPage` only).
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub start_index: Option<u64>,
269
270    /// Place: accuracy of the position coordinates in percent
271    /// `[0.0, 100.0]`.
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub accuracy: Option<f64>,
274
275    /// Place: altitude of the position in [`units`](Self::units).
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub altitude: Option<f64>,
278
279    /// Place: latitude of the position in decimal degrees.
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub latitude: Option<f64>,
282
283    /// Place: longitude of the position in decimal degrees.
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub longitude: Option<f64>,
286
287    /// Place: radius of the position in [`units`](Self::units).
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub radius: Option<f64>,
290
291    /// Place: measurement units for [`altitude`](Self::altitude) and
292    /// [`radius`](Self::radius). The AS2.0 vocabulary defines a fixed set
293    /// (`"cm"`, `"feet"`, `"inches"`, `"km"`, `"m"`, `"miles"`) but any URI
294    /// is permitted as an extension, so the raw string is preserved.
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub units: Option<String>,
297
298    /// Question: exclusive list of options (only one may be selected).
299    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
300    pub one_of: OneOrMany<ObjectRef>,
301
302    /// Question: inclusive list of options (any subset may be selected).
303    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
304    pub any_of: OneOrMany<ObjectRef>,
305
306    /// Question: indicates a question has closed. Per AS2.0 this property
307    /// is polymorphic (`xsd:dateTime` | `Object` | `Link` | `xsd:boolean`),
308    /// so the raw JSON value is preserved verbatim.
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub closed: Option<serde_json::Value>,
311
312    /// Tombstone: the `type` of the object that was deleted.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub former_type: Option<String>,
315
316    /// Tombstone: timestamp of when the object was deleted.
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub deleted: Option<DateTime<FixedOffset>>,
319
320    /// Relationship: the subject individual in the relationship.
321    ///
322    /// Renamed on the wire to avoid clashing with
323    /// [`attributed_to`](Self::attributed_to); the property name in AS2.0
324    /// is `subject`.
325    #[serde(rename = "subject", default, skip_serializing_if = "Option::is_none")]
326    pub relationship_subject: Option<Box<ObjectRef>>,
327
328    /// Relationship: kind of relationship between
329    /// [`relationship_subject`](Self::relationship_subject) and
330    /// [`object`](Self::object). May be a URI or an inlined `Relationship`
331    /// vocabulary term.
332    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
333    pub relationship: OneOrMany<ObjectRef>,
334
335    /// Profile: the object this profile describes.
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub describes: Option<Box<ObjectRef>>,
338
339    /// `ActivityPub` §4.1: short, mention-friendly handle (e.g. `"alice"`).
340    /// Mandatory on every Mastodon-style actor.
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub preferred_username: Option<String>,
343
344    /// `ActivityPub` §4.1: actor's inbox URL. Servers POST activities here
345    /// to deliver them to this actor.
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub inbox: Option<Url>,
348
349    /// `ActivityPub` §4.1: actor's outbox URL containing the
350    /// `OrderedCollection` of activities they have authored.
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub outbox: Option<Url>,
353
354    /// `ActivityPub` §4.1: collection of actors following this actor.
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub followers: Option<Url>,
357
358    /// `ActivityPub` §4.1: collection of actors this actor follows.
359    #[serde(skip_serializing_if = "Option::is_none")]
360    pub following: Option<Url>,
361
362    /// `ActivityPub` §4.1: collection of objects this actor has liked.
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub liked: Option<Url>,
365
366    /// `ActivityPub` §4.1: list of additional collections owned by this
367    /// actor (e.g. groups, lists). Rarely used in practice but defined
368    /// in the spec.
369    #[serde(default, skip_serializing_if = "Vec::is_empty")]
370    pub streams: Vec<Url>,
371
372    /// W3C Security v1 / ActivityPub-via-Mastodon: legacy Cavage-style
373    /// HTTP-Signature verification key. See [`PublicKey`].
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub public_key: Option<PublicKey>,
376
377    /// `ActivityPub` §4.1 `endpoints` block (shared inbox, OAuth
378    /// endpoints, …). See [`Endpoints`].
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub endpoints: Option<Endpoints>,
381
382    /// Mastodon `toot:featured` collection of pinned posts.
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub featured: Option<Url>,
385
386    /// Mastodon `toot:featuredTags` collection of pinned hashtags.
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub featured_tags: Option<Url>,
389
390    /// AS 2.0: whether the actor's follow requests require manual
391    /// approval (i.e. private profile).
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub manually_approves_followers: Option<bool>,
394
395    /// Mastodon `toot:discoverable`: whether the actor opts in to
396    /// inclusion in directory listings.
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub discoverable: Option<bool>,
399
400    /// Mastodon `toot:indexable`: whether posts may be indexed by
401    /// full-text search engines.
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub indexable: Option<bool>,
404
405    /// Mastodon `toot:memorial`: whether this account has been
406    /// memorialised (read-only after death).
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub memorial: Option<bool>,
409
410    /// FEP-521a `assertionMethod` array of verification methods used
411    /// for content-signing (FEP-8b32 proofs and similar).
412    #[serde(default, skip_serializing_if = "Vec::is_empty")]
413    pub assertion_method: Vec<AssertionMethod>,
414
415    /// W3C Controlled Identifiers `authentication` array of
416    /// verification methods used for proving control of the actor URL
417    /// (challenge-response auth, signed delete tombstones, …).
418    #[serde(default, skip_serializing_if = "Vec::is_empty")]
419    pub authentication: Vec<AssertionMethod>,
420
421    /// FEP-8b32: zero or more Object Integrity Proofs attached to this
422    /// document. Multiple entries form a proof chain.
423    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
424    pub proof: OneOrMany<Proof>,
425
426    /// Unknown or extension properties preserved verbatim.
427    ///
428    /// This captures any JSON property that does not map to a typed field,
429    /// ensuring lossless round-tripping through non-standard extensions
430    /// (e.g. Mastodon's `toot:` namespace fields, Lemmy's moderation
431    /// metadata, Misskey reactions).
432    #[serde(flatten)]
433    pub extra: BTreeMap<String, serde_json::Value>,
434}
435
436impl Object {
437    /// Creates an empty [`Object`] with no properties set.
438    #[must_use]
439    pub fn new() -> Self {
440        Self::default()
441    }
442
443    /// Creates an [`Object`] with the given type.
444    #[must_use]
445    pub fn with_kind(kind: impl Into<String>) -> Self {
446        Self {
447            kind: OneOrMany::one(kind.into()),
448            ..Self::default()
449        }
450    }
451
452    /// Sets the [`id`](Self::id).
453    #[must_use]
454    pub fn with_id(mut self, id: Url) -> Self {
455        self.id = Some(id);
456        self
457    }
458
459    /// Returns `true` if any of this object's declared types equals `kind`.
460    #[must_use]
461    pub fn is_kind(&self, kind: &str) -> bool {
462        self.kind.iter().any(|k| k == kind)
463    }
464
465    /// Returns the primary (first) type name, if any.
466    #[must_use]
467    pub fn primary_kind(&self) -> Option<&str> {
468        self.kind.first().map(String::as_str)
469    }
470
471    /// Returns `true` if this object is any of the five standard actor
472    /// types (Person, Group, Organization, Application, Service).
473    #[must_use]
474    pub fn is_actor(&self) -> bool {
475        self.is_kind(kind::actor::PERSON)
476            || self.is_kind(kind::actor::GROUP)
477            || self.is_kind(kind::actor::ORGANIZATION)
478            || self.is_kind(kind::actor::APPLICATION)
479            || self.is_kind(kind::actor::SERVICE)
480    }
481
482    /// Returns `true` if this object is any kind of collection or page.
483    #[must_use]
484    pub fn is_collection(&self) -> bool {
485        self.is_kind(kind::core::COLLECTION)
486            || self.is_kind(kind::core::ORDERED_COLLECTION)
487            || self.is_kind(kind::core::COLLECTION_PAGE)
488            || self.is_kind(kind::core::ORDERED_COLLECTION_PAGE)
489    }
490
491    /// Returns `true` if any of the **public** audience properties
492    /// address the `ActivityPub` `Public` pseudo-actor in any of its
493    /// spellings.
494    ///
495    /// Per [ActivityPub §5.6][public] only the public-facing addressing
496    /// fields ([`to`](Self::to), [`cc`](Self::cc),
497    /// [`audience`](Self::audience)) participate in the public-visibility
498    /// check. The [`bto`](Self::bto) and [`bcc`](Self::bcc) fields MUST be
499    /// stripped by the server before delivery to remote inboxes, so
500    /// including them here would produce incorrect results on the receiver
501    /// side.
502    ///
503    /// [public]: https://www.w3.org/TR/activitypub/#public-addressing
504    #[must_use]
505    pub fn is_public(&self) -> bool {
506        fn any_public(refs: &OneOrMany<ObjectRef>) -> bool {
507            refs.iter().any(|r| match r {
508                UrlOr::Url(u) => Public::is_public(u.as_str()),
509                UrlOr::Object(o) => o.id.as_ref().is_some_and(|u| Public::is_public(u.as_str())),
510            })
511        }
512
513        any_public(&self.to) || any_public(&self.cc) || any_public(&self.audience)
514    }
515}
516
517impl HasId for Object {
518    fn id(&self) -> Option<&Url> {
519        self.id.as_ref()
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use pretty_assertions::assert_eq;
526    use serde_json::json;
527
528    use super::*;
529
530    #[test]
531    fn empty_object_roundtrips_as_empty_json() {
532        let obj = Object::new();
533        let v = serde_json::to_value(&obj).unwrap();
534        assert_eq!(v, json!({}));
535    }
536
537    #[test]
538    fn with_kind_emits_type() {
539        let obj = Object::with_kind("Note");
540        let v = serde_json::to_value(&obj).unwrap();
541        assert_eq!(v, json!({ "type": "Note" }));
542    }
543
544    #[test]
545    fn kind_helpers_work() {
546        let note = Object::with_kind("Note");
547        assert!(note.is_kind("Note"));
548        assert_eq!(note.primary_kind(), Some("Note"));
549        assert!(!note.is_actor());
550        assert!(!note.is_collection());
551    }
552
553    #[test]
554    fn actor_detection_covers_all_standard_types() {
555        for t in [
556            kind::actor::PERSON,
557            kind::actor::GROUP,
558            kind::actor::ORGANIZATION,
559            kind::actor::APPLICATION,
560            kind::actor::SERVICE,
561        ] {
562            let a = Object::with_kind(t);
563            assert!(a.is_actor(), "{t} should be an actor");
564        }
565    }
566
567    #[test]
568    fn is_public_detects_bare_url_in_to() {
569        let mut obj = Object::with_kind("Note");
570        obj.to = OneOrMany::one(UrlOr::Url(
571            Url::parse(Public::URI).expect("Public::URI must parse"),
572        ));
573        assert!(obj.is_public());
574    }
575
576    #[test]
577    fn is_public_detects_inlined_object_in_cc() {
578        let mut obj = Object::with_kind("Note");
579        let public_obj =
580            Object::new().with_id(Url::parse(Public::URI).expect("Public::URI must parse"));
581        obj.cc = OneOrMany::one(UrlOr::Object(Box::new(public_obj)));
582        assert!(obj.is_public());
583    }
584
585    #[test]
586    fn source_and_sensitive_roundtrip_through_wire_format() {
587        let note_json = json!({
588            "type": "Note",
589            "content": "<p>rendered</p>",
590            "source": {
591                "content": "rendered",
592                "mediaType": "text/markdown",
593            },
594            "sensitive": true,
595        });
596        let obj: Object = serde_json::from_value(note_json.clone()).expect("parse");
597        assert_eq!(obj.sensitive, Some(true));
598        assert!(obj.source.is_some());
599        assert_eq!(serde_json::to_value(&obj).unwrap(), note_json);
600    }
601
602    #[test]
603    fn is_public_detects_target_in_audience() {
604        // `audience` is one of the three public-addressing fields per
605        // ActivityPub §5.6.
606        let mut obj = Object::with_kind("Note");
607        obj.audience = OneOrMany::one(UrlOr::Url(
608            Url::parse(Public::URI).expect("Public::URI must parse"),
609        ));
610        assert!(obj.is_public());
611    }
612
613    #[test]
614    fn is_public_ignores_bto_and_bcc() {
615        // Per ActivityPub §5.6, `bto`/`bcc` MUST be stripped before
616        // delivery, so they must not contribute to public visibility.
617        let mut obj = Object::with_kind("Note");
618        obj.bto = OneOrMany::one(UrlOr::Url(Url::parse(Public::URI).unwrap()));
619        assert!(!obj.is_public(), "bto must not be considered public");
620
621        let mut obj2 = Object::with_kind("Note");
622        obj2.bcc = OneOrMany::one(UrlOr::Url(Url::parse(Public::URI).unwrap()));
623        assert!(!obj2.is_public(), "bcc must not be considered public");
624    }
625
626    #[test]
627    fn place_properties_roundtrip() {
628        let raw = json!({
629            "type": "Place",
630            "name": "Work Office",
631            "latitude": 36.75,
632            "longitude": 119.7726,
633            "altitude": 90.0,
634            "accuracy": 94.5,
635            "radius": 10.5,
636            "units": "m"
637        });
638        let obj: Object = serde_json::from_value(raw.clone()).unwrap();
639        assert_eq!(obj.latitude, Some(36.75));
640        assert_eq!(obj.longitude, Some(119.7726));
641        assert_eq!(obj.altitude, Some(90.0));
642        assert_eq!(obj.accuracy, Some(94.5));
643        assert_eq!(obj.radius, Some(10.5));
644        assert_eq!(obj.units.as_deref(), Some("m"));
645        let back = serde_json::to_value(&obj).unwrap();
646        assert_eq!(back, raw);
647    }
648
649    #[test]
650    fn question_properties_roundtrip() {
651        let raw = json!({
652            "type": "Question",
653            "name": "What is your favourite colour?",
654            "oneOf": [
655                { "type": "Note", "name": "Red" },
656                { "type": "Note", "name": "Blue" }
657            ],
658            "closed": "2026-01-01T00:00:00Z"
659        });
660        let obj: Object = serde_json::from_value(raw.clone()).unwrap();
661        assert_eq!(obj.one_of.len(), 2);
662        assert!(obj.closed.is_some());
663        let back = serde_json::to_value(&obj).unwrap();
664        assert_eq!(back, raw);
665    }
666
667    #[test]
668    fn tombstone_properties_roundtrip() {
669        let raw = json!({
670            "id": "https://mastodon.social/users/alice/statuses/1",
671            "type": "Tombstone",
672            "formerType": "Note",
673            "deleted": "2026-04-20T12:00:00Z"
674        });
675        let obj: Object = serde_json::from_value(raw.clone()).unwrap();
676        assert!(obj.is_kind("Tombstone"));
677        assert_eq!(obj.former_type.as_deref(), Some("Note"));
678        assert!(obj.deleted.is_some());
679        let back = serde_json::to_value(&obj).unwrap();
680        assert_eq!(back, raw);
681    }
682
683    #[test]
684    fn relationship_properties_roundtrip() {
685        let raw = json!({
686            "type": "Relationship",
687            "subject": "https://example.com/users/alice",
688            "relationship": "http://purl.org/vocab/relationship/acquaintanceOf",
689            "object": "https://example.com/users/bob"
690        });
691        let obj: Object = serde_json::from_value(raw.clone()).unwrap();
692        assert!(obj.relationship_subject.is_some());
693        assert_eq!(obj.relationship.len(), 1);
694        assert_eq!(obj.object.len(), 1);
695        let back = serde_json::to_value(&obj).unwrap();
696        assert_eq!(back, raw);
697    }
698
699    #[test]
700    fn profile_describes_roundtrip() {
701        let raw = json!({
702            "type": "Profile",
703            "describes": {
704                "type": "Person",
705                "name": "Alice"
706            }
707        });
708        let obj: Object = serde_json::from_value(raw.clone()).unwrap();
709        assert!(obj.describes.is_some());
710        let back = serde_json::to_value(&obj).unwrap();
711        assert_eq!(back, raw);
712    }
713
714    #[test]
715    fn mastodon_note_roundtrips() {
716        let raw = json!({
717            "id": "https://mastodon.social/users/alice/statuses/1",
718            "type": "Note",
719            "attributedTo": "https://mastodon.social/users/alice",
720            "content": "<p>Hello, Fediverse</p>",
721            "published": "2026-04-20T10:00:00+00:00",
722            "to": ["https://www.w3.org/ns/activitystreams#Public"],
723            "cc": ["https://mastodon.social/users/alice/followers"],
724            "sensitive": false,
725            "inReplyTo": null
726        });
727
728        let obj: Object = serde_json::from_value(raw).unwrap();
729        assert!(obj.is_kind("Note"));
730        assert_eq!(obj.content.as_deref(), Some("<p>Hello, Fediverse</p>"));
731        assert!(obj.is_public());
732        assert_eq!(obj.attributed_to.len(), 1);
733        assert_eq!(obj.sensitive, Some(false));
734        // `inReplyTo: null` should be absorbed without failure
735    }
736
737    #[test]
738    fn extension_fields_roundtrip() {
739        let raw = json!({
740            "type": "Note",
741            "_misskey_quote": "https://misskey.example/note/abc",
742            "blurhash": "LEHV6nWB2yk8pyo0adR*.7kCMdnj"
743        });
744        let obj: Object = serde_json::from_value(raw.clone()).unwrap();
745        assert_eq!(obj.extra.len(), 2);
746        let back = serde_json::to_value(&obj).unwrap();
747        assert_eq!(back, raw);
748    }
749}