1use 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
29pub type ObjectRef = UrlOr<Box<Object>>;
35
36pub type LanguageMap = BTreeMap<String, String>;
39
40#[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 #[serde(skip_serializing_if = "Option::is_none")]
63 pub id: Option<Url>,
64
65 #[serde(rename = "type", default, skip_serializing_if = "OneOrMany::is_empty")]
68 pub kind: OneOrMany<String>,
69
70 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
72 pub attachment: OneOrMany<ObjectRef>,
73
74 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
76 pub attributed_to: OneOrMany<ObjectRef>,
77
78 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
80 pub audience: OneOrMany<ObjectRef>,
81
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub content: Option<String>,
85
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub content_map: Option<LanguageMap>,
89
90 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
95 pub context: OneOrMany<ObjectRef>,
96
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub name: Option<String>,
100
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub name_map: Option<LanguageMap>,
104
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub end_time: Option<DateTime<FixedOffset>>,
108
109 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
111 pub generator: OneOrMany<ObjectRef>,
112
113 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
115 pub icon: OneOrMany<ObjectRef>,
116
117 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
119 pub image: OneOrMany<ObjectRef>,
120
121 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
123 pub in_reply_to: OneOrMany<ObjectRef>,
124
125 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
127 pub location: OneOrMany<ObjectRef>,
128
129 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
131 pub preview: OneOrMany<ObjectRef>,
132
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub published: Option<DateTime<FixedOffset>>,
136
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub replies: Option<Box<ObjectRef>>,
140
141 #[serde(skip_serializing_if = "Option::is_none")]
143 pub start_time: Option<DateTime<FixedOffset>>,
144
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub summary: Option<String>,
148
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub summary_map: Option<LanguageMap>,
152
153 #[serde(skip_serializing_if = "Option::is_none")]
160 pub source: Option<Box<ObjectRef>>,
161
162 #[serde(skip_serializing_if = "Option::is_none")]
168 pub sensitive: Option<bool>,
169
170 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
172 pub tag: OneOrMany<ObjectRef>,
173
174 #[serde(skip_serializing_if = "Option::is_none")]
176 pub updated: Option<DateTime<FixedOffset>>,
177
178 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
180 pub url: OneOrMany<ObjectRef>,
181
182 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
184 pub to: OneOrMany<ObjectRef>,
185
186 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
188 pub bto: OneOrMany<ObjectRef>,
189
190 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
192 pub cc: OneOrMany<ObjectRef>,
193
194 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
196 pub bcc: OneOrMany<ObjectRef>,
197
198 #[serde(skip_serializing_if = "Option::is_none")]
200 pub media_type: Option<String>,
201
202 #[serde(skip_serializing_if = "Option::is_none")]
204 pub duration: Option<String>,
205
206 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
208 pub actor: OneOrMany<ObjectRef>,
209
210 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
212 pub object: OneOrMany<ObjectRef>,
213
214 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
216 pub target: OneOrMany<ObjectRef>,
217
218 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
220 pub result: OneOrMany<ObjectRef>,
221
222 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
224 pub origin: OneOrMany<ObjectRef>,
225
226 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
228 pub instrument: OneOrMany<ObjectRef>,
229
230 #[serde(skip_serializing_if = "Option::is_none")]
232 pub total_items: Option<u64>,
233
234 #[serde(skip_serializing_if = "Option::is_none")]
236 pub current: Option<Box<ObjectRef>>,
237
238 #[serde(skip_serializing_if = "Option::is_none")]
240 pub first: Option<Box<ObjectRef>>,
241
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub last: Option<Box<ObjectRef>>,
245
246 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
248 pub items: OneOrMany<ObjectRef>,
249
250 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
252 pub ordered_items: OneOrMany<ObjectRef>,
253
254 #[serde(skip_serializing_if = "Option::is_none")]
256 pub part_of: Option<Box<ObjectRef>>,
257
258 #[serde(skip_serializing_if = "Option::is_none")]
260 pub next: Option<Box<ObjectRef>>,
261
262 #[serde(skip_serializing_if = "Option::is_none")]
264 pub prev: Option<Box<ObjectRef>>,
265
266 #[serde(skip_serializing_if = "Option::is_none")]
268 pub start_index: Option<u64>,
269
270 #[serde(skip_serializing_if = "Option::is_none")]
273 pub accuracy: Option<f64>,
274
275 #[serde(skip_serializing_if = "Option::is_none")]
277 pub altitude: Option<f64>,
278
279 #[serde(skip_serializing_if = "Option::is_none")]
281 pub latitude: Option<f64>,
282
283 #[serde(skip_serializing_if = "Option::is_none")]
285 pub longitude: Option<f64>,
286
287 #[serde(skip_serializing_if = "Option::is_none")]
289 pub radius: Option<f64>,
290
291 #[serde(skip_serializing_if = "Option::is_none")]
296 pub units: Option<String>,
297
298 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
300 pub one_of: OneOrMany<ObjectRef>,
301
302 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
304 pub any_of: OneOrMany<ObjectRef>,
305
306 #[serde(skip_serializing_if = "Option::is_none")]
310 pub closed: Option<serde_json::Value>,
311
312 #[serde(skip_serializing_if = "Option::is_none")]
314 pub former_type: Option<String>,
315
316 #[serde(skip_serializing_if = "Option::is_none")]
318 pub deleted: Option<DateTime<FixedOffset>>,
319
320 #[serde(rename = "subject", default, skip_serializing_if = "Option::is_none")]
326 pub relationship_subject: Option<Box<ObjectRef>>,
327
328 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
333 pub relationship: OneOrMany<ObjectRef>,
334
335 #[serde(skip_serializing_if = "Option::is_none")]
337 pub describes: Option<Box<ObjectRef>>,
338
339 #[serde(skip_serializing_if = "Option::is_none")]
342 pub preferred_username: Option<String>,
343
344 #[serde(skip_serializing_if = "Option::is_none")]
347 pub inbox: Option<Url>,
348
349 #[serde(skip_serializing_if = "Option::is_none")]
352 pub outbox: Option<Url>,
353
354 #[serde(skip_serializing_if = "Option::is_none")]
356 pub followers: Option<Url>,
357
358 #[serde(skip_serializing_if = "Option::is_none")]
360 pub following: Option<Url>,
361
362 #[serde(skip_serializing_if = "Option::is_none")]
364 pub liked: Option<Url>,
365
366 #[serde(default, skip_serializing_if = "Vec::is_empty")]
370 pub streams: Vec<Url>,
371
372 #[serde(skip_serializing_if = "Option::is_none")]
375 pub public_key: Option<PublicKey>,
376
377 #[serde(skip_serializing_if = "Option::is_none")]
380 pub endpoints: Option<Endpoints>,
381
382 #[serde(skip_serializing_if = "Option::is_none")]
384 pub featured: Option<Url>,
385
386 #[serde(skip_serializing_if = "Option::is_none")]
388 pub featured_tags: Option<Url>,
389
390 #[serde(skip_serializing_if = "Option::is_none")]
393 pub manually_approves_followers: Option<bool>,
394
395 #[serde(skip_serializing_if = "Option::is_none")]
398 pub discoverable: Option<bool>,
399
400 #[serde(skip_serializing_if = "Option::is_none")]
403 pub indexable: Option<bool>,
404
405 #[serde(skip_serializing_if = "Option::is_none")]
408 pub memorial: Option<bool>,
409
410 #[serde(default, skip_serializing_if = "Vec::is_empty")]
413 pub assertion_method: Vec<AssertionMethod>,
414
415 #[serde(default, skip_serializing_if = "Vec::is_empty")]
419 pub authentication: Vec<AssertionMethod>,
420
421 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
424 pub proof: OneOrMany<Proof>,
425
426 #[serde(flatten)]
433 pub extra: BTreeMap<String, serde_json::Value>,
434}
435
436impl Object {
437 #[must_use]
439 pub fn new() -> Self {
440 Self::default()
441 }
442
443 #[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 #[must_use]
454 pub fn with_id(mut self, id: Url) -> Self {
455 self.id = Some(id);
456 self
457 }
458
459 #[must_use]
461 pub fn is_kind(&self, kind: &str) -> bool {
462 self.kind.iter().any(|k| k == kind)
463 }
464
465 #[must_use]
467 pub fn primary_kind(&self) -> Option<&str> {
468 self.kind.first().map(String::as_str)
469 }
470
471 #[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 #[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 #[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 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 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 }
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}