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(default, skip_serializing_if = "OneOrMany::is_empty")]
155 pub tag: OneOrMany<ObjectRef>,
156
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub updated: Option<DateTime<FixedOffset>>,
160
161 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
163 pub url: OneOrMany<ObjectRef>,
164
165 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
167 pub to: OneOrMany<ObjectRef>,
168
169 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
171 pub bto: OneOrMany<ObjectRef>,
172
173 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
175 pub cc: OneOrMany<ObjectRef>,
176
177 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
179 pub bcc: OneOrMany<ObjectRef>,
180
181 #[serde(skip_serializing_if = "Option::is_none")]
183 pub media_type: Option<String>,
184
185 #[serde(skip_serializing_if = "Option::is_none")]
187 pub duration: Option<String>,
188
189 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
191 pub actor: OneOrMany<ObjectRef>,
192
193 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
195 pub object: OneOrMany<ObjectRef>,
196
197 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
199 pub target: OneOrMany<ObjectRef>,
200
201 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
203 pub result: OneOrMany<ObjectRef>,
204
205 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
207 pub origin: OneOrMany<ObjectRef>,
208
209 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
211 pub instrument: OneOrMany<ObjectRef>,
212
213 #[serde(skip_serializing_if = "Option::is_none")]
215 pub total_items: Option<u64>,
216
217 #[serde(skip_serializing_if = "Option::is_none")]
219 pub current: Option<Box<ObjectRef>>,
220
221 #[serde(skip_serializing_if = "Option::is_none")]
223 pub first: Option<Box<ObjectRef>>,
224
225 #[serde(skip_serializing_if = "Option::is_none")]
227 pub last: Option<Box<ObjectRef>>,
228
229 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
231 pub items: OneOrMany<ObjectRef>,
232
233 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
235 pub ordered_items: OneOrMany<ObjectRef>,
236
237 #[serde(skip_serializing_if = "Option::is_none")]
239 pub part_of: Option<Box<ObjectRef>>,
240
241 #[serde(skip_serializing_if = "Option::is_none")]
243 pub next: Option<Box<ObjectRef>>,
244
245 #[serde(skip_serializing_if = "Option::is_none")]
247 pub prev: Option<Box<ObjectRef>>,
248
249 #[serde(skip_serializing_if = "Option::is_none")]
251 pub start_index: Option<u64>,
252
253 #[serde(skip_serializing_if = "Option::is_none")]
256 pub accuracy: Option<f64>,
257
258 #[serde(skip_serializing_if = "Option::is_none")]
260 pub altitude: Option<f64>,
261
262 #[serde(skip_serializing_if = "Option::is_none")]
264 pub latitude: Option<f64>,
265
266 #[serde(skip_serializing_if = "Option::is_none")]
268 pub longitude: Option<f64>,
269
270 #[serde(skip_serializing_if = "Option::is_none")]
272 pub radius: Option<f64>,
273
274 #[serde(skip_serializing_if = "Option::is_none")]
279 pub units: Option<String>,
280
281 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
283 pub one_of: OneOrMany<ObjectRef>,
284
285 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
287 pub any_of: OneOrMany<ObjectRef>,
288
289 #[serde(skip_serializing_if = "Option::is_none")]
293 pub closed: Option<serde_json::Value>,
294
295 #[serde(skip_serializing_if = "Option::is_none")]
297 pub former_type: Option<String>,
298
299 #[serde(skip_serializing_if = "Option::is_none")]
301 pub deleted: Option<DateTime<FixedOffset>>,
302
303 #[serde(rename = "subject", default, skip_serializing_if = "Option::is_none")]
309 pub relationship_subject: Option<Box<ObjectRef>>,
310
311 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
316 pub relationship: OneOrMany<ObjectRef>,
317
318 #[serde(skip_serializing_if = "Option::is_none")]
320 pub describes: Option<Box<ObjectRef>>,
321
322 #[serde(skip_serializing_if = "Option::is_none")]
325 pub preferred_username: Option<String>,
326
327 #[serde(skip_serializing_if = "Option::is_none")]
330 pub inbox: Option<Url>,
331
332 #[serde(skip_serializing_if = "Option::is_none")]
335 pub outbox: Option<Url>,
336
337 #[serde(skip_serializing_if = "Option::is_none")]
339 pub followers: Option<Url>,
340
341 #[serde(skip_serializing_if = "Option::is_none")]
343 pub following: Option<Url>,
344
345 #[serde(skip_serializing_if = "Option::is_none")]
347 pub liked: Option<Url>,
348
349 #[serde(default, skip_serializing_if = "Vec::is_empty")]
353 pub streams: Vec<Url>,
354
355 #[serde(skip_serializing_if = "Option::is_none")]
358 pub public_key: Option<PublicKey>,
359
360 #[serde(skip_serializing_if = "Option::is_none")]
363 pub endpoints: Option<Endpoints>,
364
365 #[serde(skip_serializing_if = "Option::is_none")]
367 pub featured: Option<Url>,
368
369 #[serde(skip_serializing_if = "Option::is_none")]
371 pub featured_tags: Option<Url>,
372
373 #[serde(skip_serializing_if = "Option::is_none")]
376 pub manually_approves_followers: Option<bool>,
377
378 #[serde(skip_serializing_if = "Option::is_none")]
381 pub discoverable: Option<bool>,
382
383 #[serde(skip_serializing_if = "Option::is_none")]
386 pub indexable: Option<bool>,
387
388 #[serde(skip_serializing_if = "Option::is_none")]
391 pub memorial: Option<bool>,
392
393 #[serde(default, skip_serializing_if = "Vec::is_empty")]
396 pub assertion_method: Vec<AssertionMethod>,
397
398 #[serde(default, skip_serializing_if = "Vec::is_empty")]
402 pub authentication: Vec<AssertionMethod>,
403
404 #[serde(default, skip_serializing_if = "OneOrMany::is_empty")]
407 pub proof: OneOrMany<Proof>,
408
409 #[serde(flatten)]
416 pub extra: BTreeMap<String, serde_json::Value>,
417}
418
419impl Object {
420 #[must_use]
422 pub fn new() -> Self {
423 Self::default()
424 }
425
426 #[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 #[must_use]
437 pub fn with_id(mut self, id: Url) -> Self {
438 self.id = Some(id);
439 self
440 }
441
442 #[must_use]
444 pub fn is_kind(&self, kind: &str) -> bool {
445 self.kind.iter().any(|k| k == kind)
446 }
447
448 #[must_use]
450 pub fn primary_kind(&self) -> Option<&str> {
451 self.kind.first().map(String::as_str)
452 }
453
454 #[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 #[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 #[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 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 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 }
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}