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    /// Tags (mentions, hashtags, emoji) linked to this object.
154    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
155    pub tag: OneOrMany<ObjectRef>,
156
157    /// Last-updated timestamp (`xsd:dateTime`).
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub updated: Option<DateTime<FixedOffset>>,
160
161    /// URL(s) providing alternate representations of this object.
162    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
163    pub url: OneOrMany<ObjectRef>,
164
165    /// Public primary recipients.
166    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
167    pub to: OneOrMany<ObjectRef>,
168
169    /// Private primary recipients (stripped before delivery).
170    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
171    pub bto: OneOrMany<ObjectRef>,
172
173    /// Public secondary recipients.
174    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
175    pub cc: OneOrMany<ObjectRef>,
176
177    /// Private secondary recipients (stripped before delivery).
178    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
179    pub bcc: OneOrMany<ObjectRef>,
180
181    /// MIME type of this object's payload.
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub media_type: Option<String>,
184
185    /// `xsd:duration` lexical form (e.g. `"PT5M"`).
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub duration: Option<String>,
188
189    /// One or more actors performing the activity.
190    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
191    pub actor: OneOrMany<ObjectRef>,
192
193    /// Object of the activity. Omitted for `IntransitiveActivity`.
194    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
195    pub object: OneOrMany<ObjectRef>,
196
197    /// Indirect target of the activity.
198    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
199    pub target: OneOrMany<ObjectRef>,
200
201    /// Result of the activity.
202    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
203    pub result: OneOrMany<ObjectRef>,
204
205    /// Origin from which the activity proceeds.
206    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
207    pub origin: OneOrMany<ObjectRef>,
208
209    /// Instrument used to perform the activity.
210    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
211    pub instrument: OneOrMany<ObjectRef>,
212
213    /// Number of items in the collection.
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub total_items: Option<u64>,
216
217    /// Current page of a paged collection.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub current: Option<Box<ObjectRef>>,
220
221    /// First page of a paged collection.
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub first: Option<Box<ObjectRef>>,
224
225    /// Last page of a paged collection.
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub last: Option<Box<ObjectRef>>,
228
229    /// Items in an unordered collection.
230    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
231    pub items: OneOrMany<ObjectRef>,
232
233    /// Items in an ordered collection.
234    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
235    pub ordered_items: OneOrMany<ObjectRef>,
236
237    /// Collection this page is part of.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub part_of: Option<Box<ObjectRef>>,
240
241    /// Next page.
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub next: Option<Box<ObjectRef>>,
244
245    /// Previous page.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub prev: Option<Box<ObjectRef>>,
248
249    /// Starting index (`OrderedCollectionPage` only).
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub start_index: Option<u64>,
252
253    /// Place: accuracy of the position coordinates in percent
254    /// `[0.0, 100.0]`.
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub accuracy: Option<f64>,
257
258    /// Place: altitude of the position in [`units`](Self::units).
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub altitude: Option<f64>,
261
262    /// Place: latitude of the position in decimal degrees.
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub latitude: Option<f64>,
265
266    /// Place: longitude of the position in decimal degrees.
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub longitude: Option<f64>,
269
270    /// Place: radius of the position in [`units`](Self::units).
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub radius: Option<f64>,
273
274    /// Place: measurement units for [`altitude`](Self::altitude) and
275    /// [`radius`](Self::radius). The AS2.0 vocabulary defines a fixed set
276    /// (`"cm"`, `"feet"`, `"inches"`, `"km"`, `"m"`, `"miles"`) but any URI
277    /// is permitted as an extension, so the raw string is preserved.
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub units: Option<String>,
280
281    /// Question: exclusive list of options (only one may be selected).
282    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
283    pub one_of: OneOrMany<ObjectRef>,
284
285    /// Question: inclusive list of options (any subset may be selected).
286    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
287    pub any_of: OneOrMany<ObjectRef>,
288
289    /// Question: indicates a question has closed. Per AS2.0 this property
290    /// is polymorphic (`xsd:dateTime` | `Object` | `Link` | `xsd:boolean`),
291    /// so the raw JSON value is preserved verbatim.
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub closed: Option<serde_json::Value>,
294
295    /// Tombstone: the `type` of the object that was deleted.
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub former_type: Option<String>,
298
299    /// Tombstone: timestamp of when the object was deleted.
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub deleted: Option<DateTime<FixedOffset>>,
302
303    /// Relationship: the subject individual in the relationship.
304    ///
305    /// Renamed on the wire to avoid clashing with
306    /// [`attributed_to`](Self::attributed_to); the property name in AS2.0
307    /// is `subject`.
308    #[serde(rename = "subject", default, skip_serializing_if = "Option::is_none")]
309    pub relationship_subject: Option<Box<ObjectRef>>,
310
311    /// Relationship: kind of relationship between
312    /// [`relationship_subject`](Self::relationship_subject) and
313    /// [`object`](Self::object). May be a URI or an inlined `Relationship`
314    /// vocabulary term.
315    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
316    pub relationship: OneOrMany<ObjectRef>,
317
318    /// Profile: the object this profile describes.
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub describes: Option<Box<ObjectRef>>,
321
322    /// `ActivityPub` §4.1: short, mention-friendly handle (e.g. `"alice"`).
323    /// Mandatory on every Mastodon-style actor.
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub preferred_username: Option<String>,
326
327    /// `ActivityPub` §4.1: actor's inbox URL. Servers POST activities here
328    /// to deliver them to this actor.
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub inbox: Option<Url>,
331
332    /// `ActivityPub` §4.1: actor's outbox URL containing the
333    /// `OrderedCollection` of activities they have authored.
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub outbox: Option<Url>,
336
337    /// `ActivityPub` §4.1: collection of actors following this actor.
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub followers: Option<Url>,
340
341    /// `ActivityPub` §4.1: collection of actors this actor follows.
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub following: Option<Url>,
344
345    /// `ActivityPub` §4.1: collection of objects this actor has liked.
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub liked: Option<Url>,
348
349    /// `ActivityPub` §4.1: list of additional collections owned by this
350    /// actor (e.g. groups, lists). Rarely used in practice but defined
351    /// in the spec.
352    #[serde(default, skip_serializing_if = "Vec::is_empty")]
353    pub streams: Vec<Url>,
354
355    /// W3C Security v1 / ActivityPub-via-Mastodon: legacy Cavage-style
356    /// HTTP-Signature verification key. See [`PublicKey`].
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub public_key: Option<PublicKey>,
359
360    /// `ActivityPub` §4.1 `endpoints` block (shared inbox, OAuth
361    /// endpoints, …). See [`Endpoints`].
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub endpoints: Option<Endpoints>,
364
365    /// Mastodon `toot:featured` collection of pinned posts.
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub featured: Option<Url>,
368
369    /// Mastodon `toot:featuredTags` collection of pinned hashtags.
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub featured_tags: Option<Url>,
372
373    /// AS 2.0: whether the actor's follow requests require manual
374    /// approval (i.e. private profile).
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub manually_approves_followers: Option<bool>,
377
378    /// Mastodon `toot:discoverable`: whether the actor opts in to
379    /// inclusion in directory listings.
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub discoverable: Option<bool>,
382
383    /// Mastodon `toot:indexable`: whether posts may be indexed by
384    /// full-text search engines.
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub indexable: Option<bool>,
387
388    /// Mastodon `toot:memorial`: whether this account has been
389    /// memorialised (read-only after death).
390    #[serde(skip_serializing_if = "Option::is_none")]
391    pub memorial: Option<bool>,
392
393    /// FEP-521a `assertionMethod` array of verification methods used
394    /// for content-signing (FEP-8b32 proofs and similar).
395    #[serde(default, skip_serializing_if = "Vec::is_empty")]
396    pub assertion_method: Vec<AssertionMethod>,
397
398    /// W3C Controlled Identifiers `authentication` array of
399    /// verification methods used for proving control of the actor URL
400    /// (challenge-response auth, signed delete tombstones, …).
401    #[serde(default, skip_serializing_if = "Vec::is_empty")]
402    pub authentication: Vec<AssertionMethod>,
403
404    /// FEP-8b32: zero or more Object Integrity Proofs attached to this
405    /// document. Multiple entries form a proof chain.
406    #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
407    pub proof: OneOrMany<Proof>,
408
409    /// Unknown or extension properties preserved verbatim.
410    ///
411    /// This captures any JSON property that does not map to a typed field,
412    /// ensuring lossless round-tripping through non-standard extensions
413    /// (e.g. Mastodon's `toot:` namespace fields, Lemmy's moderation
414    /// metadata, Misskey reactions).
415    #[serde(flatten)]
416    pub extra: BTreeMap<String, serde_json::Value>,
417}
418
419impl Object {
420    /// Creates an empty [`Object`] with no properties set.
421    #[must_use]
422    pub fn new() -> Self {
423        Self::default()
424    }
425
426    /// Creates an [`Object`] with the given type.
427    #[must_use]
428    pub fn with_kind(kind: impl Into<String>) -> Self {
429        Self {
430            kind: OneOrMany::one(kind.into()),
431            ..Self::default()
432        }
433    }
434
435    /// Sets the [`id`](Self::id).
436    #[must_use]
437    pub fn with_id(mut self, id: Url) -> Self {
438        self.id = Some(id);
439        self
440    }
441
442    /// Returns `true` if any of this object's declared types equals `kind`.
443    #[must_use]
444    pub fn is_kind(&self, kind: &str) -> bool {
445        self.kind.iter().any(|k| k == kind)
446    }
447
448    /// Returns the primary (first) type name, if any.
449    #[must_use]
450    pub fn primary_kind(&self) -> Option<&str> {
451        self.kind.first().map(String::as_str)
452    }
453
454    /// Returns `true` if this object is any of the five standard actor
455    /// types (Person, Group, Organization, Application, Service).
456    #[must_use]
457    pub fn is_actor(&self) -> bool {
458        self.is_kind(kind::actor::PERSON)
459            || self.is_kind(kind::actor::GROUP)
460            || self.is_kind(kind::actor::ORGANIZATION)
461            || self.is_kind(kind::actor::APPLICATION)
462            || self.is_kind(kind::actor::SERVICE)
463    }
464
465    /// Returns `true` if this object is any kind of collection or page.
466    #[must_use]
467    pub fn is_collection(&self) -> bool {
468        self.is_kind(kind::core::COLLECTION)
469            || self.is_kind(kind::core::ORDERED_COLLECTION)
470            || self.is_kind(kind::core::COLLECTION_PAGE)
471            || self.is_kind(kind::core::ORDERED_COLLECTION_PAGE)
472    }
473
474    /// Returns `true` if any of the **public** audience properties
475    /// address the `ActivityPub` `Public` pseudo-actor in any of its
476    /// spellings.
477    ///
478    /// Per [ActivityPub §5.6][public] only the public-facing addressing
479    /// fields ([`to`](Self::to), [`cc`](Self::cc),
480    /// [`audience`](Self::audience)) participate in the public-visibility
481    /// check. The [`bto`](Self::bto) and [`bcc`](Self::bcc) fields MUST be
482    /// stripped by the server before delivery to remote inboxes, so
483    /// including them here would produce incorrect results on the receiver
484    /// side.
485    ///
486    /// [public]: https://www.w3.org/TR/activitypub/#public-addressing
487    #[must_use]
488    pub fn is_public(&self) -> bool {
489        fn any_public(refs: &OneOrMany<ObjectRef>) -> bool {
490            refs.iter().any(|r| match r {
491                UrlOr::Url(u) => Public::is_public(u.as_str()),
492                UrlOr::Object(o) => o.id.as_ref().is_some_and(|u| Public::is_public(u.as_str())),
493            })
494        }
495
496        any_public(&self.to) || any_public(&self.cc) || any_public(&self.audience)
497    }
498}
499
500impl HasId for Object {
501    fn id(&self) -> Option<&Url> {
502        self.id.as_ref()
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use pretty_assertions::assert_eq;
509    use serde_json::json;
510
511    use super::*;
512
513    #[test]
514    fn empty_object_roundtrips_as_empty_json() {
515        let obj = Object::new();
516        let v = serde_json::to_value(&obj).unwrap();
517        assert_eq!(v, json!({}));
518    }
519
520    #[test]
521    fn with_kind_emits_type() {
522        let obj = Object::with_kind("Note");
523        let v = serde_json::to_value(&obj).unwrap();
524        assert_eq!(v, json!({ "type": "Note" }));
525    }
526
527    #[test]
528    fn kind_helpers_work() {
529        let note = Object::with_kind("Note");
530        assert!(note.is_kind("Note"));
531        assert_eq!(note.primary_kind(), Some("Note"));
532        assert!(!note.is_actor());
533        assert!(!note.is_collection());
534    }
535
536    #[test]
537    fn actor_detection_covers_all_standard_types() {
538        for t in [
539            kind::actor::PERSON,
540            kind::actor::GROUP,
541            kind::actor::ORGANIZATION,
542            kind::actor::APPLICATION,
543            kind::actor::SERVICE,
544        ] {
545            let a = Object::with_kind(t);
546            assert!(a.is_actor(), "{t} should be an actor");
547        }
548    }
549
550    #[test]
551    fn is_public_detects_bare_url_in_to() {
552        let mut obj = Object::with_kind("Note");
553        obj.to = OneOrMany::one(UrlOr::Url(
554            Url::parse(Public::URI).expect("Public::URI must parse"),
555        ));
556        assert!(obj.is_public());
557    }
558
559    #[test]
560    fn is_public_detects_inlined_object_in_cc() {
561        let mut obj = Object::with_kind("Note");
562        let public_obj =
563            Object::new().with_id(Url::parse(Public::URI).expect("Public::URI must parse"));
564        obj.cc = OneOrMany::one(UrlOr::Object(Box::new(public_obj)));
565        assert!(obj.is_public());
566    }
567
568    #[test]
569    fn is_public_detects_target_in_audience() {
570        // `audience` is one of the three public-addressing fields per
571        // ActivityPub §5.6.
572        let mut obj = Object::with_kind("Note");
573        obj.audience = OneOrMany::one(UrlOr::Url(
574            Url::parse(Public::URI).expect("Public::URI must parse"),
575        ));
576        assert!(obj.is_public());
577    }
578
579    #[test]
580    fn is_public_ignores_bto_and_bcc() {
581        // Per ActivityPub §5.6, `bto`/`bcc` MUST be stripped before
582        // delivery, so they must not contribute to public visibility.
583        let mut obj = Object::with_kind("Note");
584        obj.bto = OneOrMany::one(UrlOr::Url(Url::parse(Public::URI).unwrap()));
585        assert!(!obj.is_public(), "bto must not be considered public");
586
587        let mut obj2 = Object::with_kind("Note");
588        obj2.bcc = OneOrMany::one(UrlOr::Url(Url::parse(Public::URI).unwrap()));
589        assert!(!obj2.is_public(), "bcc must not be considered public");
590    }
591
592    #[test]
593    fn place_properties_roundtrip() {
594        let raw = json!({
595            "type": "Place",
596            "name": "Work Office",
597            "latitude": 36.75,
598            "longitude": 119.7726,
599            "altitude": 90.0,
600            "accuracy": 94.5,
601            "radius": 10.5,
602            "units": "m"
603        });
604        let obj: Object = serde_json::from_value(raw.clone()).unwrap();
605        assert_eq!(obj.latitude, Some(36.75));
606        assert_eq!(obj.longitude, Some(119.7726));
607        assert_eq!(obj.altitude, Some(90.0));
608        assert_eq!(obj.accuracy, Some(94.5));
609        assert_eq!(obj.radius, Some(10.5));
610        assert_eq!(obj.units.as_deref(), Some("m"));
611        let back = serde_json::to_value(&obj).unwrap();
612        assert_eq!(back, raw);
613    }
614
615    #[test]
616    fn question_properties_roundtrip() {
617        let raw = json!({
618            "type": "Question",
619            "name": "What is your favourite colour?",
620            "oneOf": [
621                { "type": "Note", "name": "Red" },
622                { "type": "Note", "name": "Blue" }
623            ],
624            "closed": "2026-01-01T00:00:00Z"
625        });
626        let obj: Object = serde_json::from_value(raw.clone()).unwrap();
627        assert_eq!(obj.one_of.len(), 2);
628        assert!(obj.closed.is_some());
629        let back = serde_json::to_value(&obj).unwrap();
630        assert_eq!(back, raw);
631    }
632
633    #[test]
634    fn tombstone_properties_roundtrip() {
635        let raw = json!({
636            "id": "https://mastodon.social/users/alice/statuses/1",
637            "type": "Tombstone",
638            "formerType": "Note",
639            "deleted": "2026-04-20T12:00:00Z"
640        });
641        let obj: Object = serde_json::from_value(raw.clone()).unwrap();
642        assert!(obj.is_kind("Tombstone"));
643        assert_eq!(obj.former_type.as_deref(), Some("Note"));
644        assert!(obj.deleted.is_some());
645        let back = serde_json::to_value(&obj).unwrap();
646        assert_eq!(back, raw);
647    }
648
649    #[test]
650    fn relationship_properties_roundtrip() {
651        let raw = json!({
652            "type": "Relationship",
653            "subject": "https://example.com/users/alice",
654            "relationship": "http://purl.org/vocab/relationship/acquaintanceOf",
655            "object": "https://example.com/users/bob"
656        });
657        let obj: Object = serde_json::from_value(raw.clone()).unwrap();
658        assert!(obj.relationship_subject.is_some());
659        assert_eq!(obj.relationship.len(), 1);
660        assert_eq!(obj.object.len(), 1);
661        let back = serde_json::to_value(&obj).unwrap();
662        assert_eq!(back, raw);
663    }
664
665    #[test]
666    fn profile_describes_roundtrip() {
667        let raw = json!({
668            "type": "Profile",
669            "describes": {
670                "type": "Person",
671                "name": "Alice"
672            }
673        });
674        let obj: Object = serde_json::from_value(raw.clone()).unwrap();
675        assert!(obj.describes.is_some());
676        let back = serde_json::to_value(&obj).unwrap();
677        assert_eq!(back, raw);
678    }
679
680    #[test]
681    fn mastodon_note_roundtrips() {
682        let raw = json!({
683            "id": "https://mastodon.social/users/alice/statuses/1",
684            "type": "Note",
685            "attributedTo": "https://mastodon.social/users/alice",
686            "content": "<p>Hello, Fediverse</p>",
687            "published": "2026-04-20T10:00:00+00:00",
688            "to": ["https://www.w3.org/ns/activitystreams#Public"],
689            "cc": ["https://mastodon.social/users/alice/followers"],
690            "sensitive": false,
691            "inReplyTo": null
692        });
693
694        let obj: Object = serde_json::from_value(raw).unwrap();
695        assert!(obj.is_kind("Note"));
696        assert_eq!(obj.content.as_deref(), Some("<p>Hello, Fediverse</p>"));
697        assert!(obj.is_public());
698        assert_eq!(obj.attributed_to.len(), 1);
699        assert!(obj.extra.contains_key("sensitive"));
700        // `inReplyTo: null` should be absorbed without failure
701    }
702
703    #[test]
704    fn extension_fields_roundtrip() {
705        let raw = json!({
706            "type": "Note",
707            "_misskey_quote": "https://misskey.example/note/abc",
708            "blurhash": "LEHV6nWB2yk8pyo0adR*.7kCMdnj"
709        });
710        let obj: Object = serde_json::from_value(raw.clone()).unwrap();
711        assert_eq!(obj.extra.len(), 2);
712        let back = serde_json::to_value(&obj).unwrap();
713        assert_eq!(back, raw);
714    }
715}