Skip to main content

actpub_activitystreams/
link.rs

1//! Activity Streams 2.0 [`Link`] object.
2//!
3//! A `Link` is an indirect reference to a resource, providing metadata about
4//! that resource such as its language, MIME type, dimensions, and relation.
5//! In AS 2.0 a [`Link`] is *disjoint* from an
6//! [`Object`](crate::Object) — the two base types never overlap on the same
7//! node. Accordingly this module defines [`Link`] as its own strict struct
8//! rather than reusing the universal [`Object`](crate::Object) container.
9
10use std::collections::BTreeMap;
11
12use serde::{Deserialize, Serialize};
13use url::Url;
14
15use crate::kind;
16use crate::value::{HasId, OneOrMany};
17
18/// An Activity Streams 2.0 [`Link`](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-link).
19///
20/// The `kind` field distinguishes link subtypes such as
21/// [`Mention`](kind::link::MENTION) and [`Hashtag`](kind::link::HASHTAG).
22///
23/// # Examples
24///
25/// ```
26/// # use actpub_activitystreams::Link;
27/// # use url::Url;
28/// let link = Link::new(Url::parse("https://example/note/1").unwrap());
29/// let json = serde_json::to_string(&link).unwrap();
30/// assert!(json.contains(r#""type":"Link""#));
31/// ```
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub struct Link {
35    /// Optional identifier for this link object.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub id: Option<Url>,
38
39    /// Type of this link. Defaults to [`"Link"`](kind::core::LINK).
40    #[serde(rename = "type", default = "Link::default_kind")]
41    pub kind: OneOrMany<String>,
42
43    /// The target URL referenced by this link.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub href: Option<Url>,
46
47    /// Link relation types (RFC 5988).
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub rel: Option<OneOrMany<String>>,
50
51    /// MIME type of the referenced resource.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub media_type: Option<String>,
54
55    /// Plain-text display name for the link.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub name: Option<String>,
58
59    /// Localized display names keyed by BCP-47 language tag.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub name_map: Option<BTreeMap<String, String>>,
62
63    /// BCP-47 language tag describing the language of the referenced resource.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub hreflang: Option<String>,
66
67    /// Display height for media links.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub height: Option<u64>,
70
71    /// Display width for media links.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub width: Option<u64>,
74
75    /// Preview resource associated with the link target.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub preview: Option<Box<crate::object::ObjectRef>>,
78
79    /// Additional or extension properties preserved verbatim across
80    /// (de)serialization.
81    #[serde(flatten)]
82    pub extra: BTreeMap<String, serde_json::Value>,
83}
84
85impl Link {
86    fn default_kind() -> OneOrMany<String> {
87        OneOrMany::one(kind::core::LINK.to_owned())
88    }
89
90    /// Creates a new bare [`Link`] pointing at `href`.
91    #[must_use]
92    pub fn new(href: Url) -> Self {
93        Self {
94            id: None,
95            kind: Self::default_kind(),
96            href: Some(href),
97            rel: None,
98            media_type: None,
99            name: None,
100            name_map: None,
101            hreflang: None,
102            height: None,
103            width: None,
104            preview: None,
105            extra: BTreeMap::new(),
106        }
107    }
108
109    /// Creates a [`Mention`](kind::link::MENTION) link pointing to an actor.
110    #[must_use]
111    pub fn mention(href: Url) -> Self {
112        let mut link = Self::new(href);
113        link.kind = OneOrMany::one(kind::link::MENTION.to_owned());
114        link
115    }
116
117    /// Creates a [`Hashtag`](kind::link::HASHTAG) link with the given name.
118    #[must_use]
119    pub fn hashtag(href: Url, name: impl Into<String>) -> Self {
120        let mut link = Self::new(href);
121        link.kind = OneOrMany::one(kind::link::HASHTAG.to_owned());
122        link.name = Some(name.into());
123        link
124    }
125
126    /// Returns `true` if this link declares the given type name.
127    #[must_use]
128    pub fn is_kind(&self, kind: &str) -> bool {
129        self.kind.iter().any(|k| k == kind)
130    }
131}
132
133impl HasId for Link {
134    fn id(&self) -> Option<&Url> {
135        self.id.as_ref()
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use pretty_assertions::assert_eq;
142    use serde_json::json;
143
144    use super::*;
145
146    #[test]
147    fn link_defaults_type_to_link() {
148        let link = Link::new(Url::parse("https://example/x").unwrap());
149        let v = serde_json::to_value(&link).unwrap();
150        assert_eq!(v["type"], json!("Link"));
151    }
152
153    #[test]
154    fn mention_sets_mention_type() {
155        let link = Link::mention(Url::parse("https://example/u/alice").unwrap());
156        assert!(link.is_kind(kind::link::MENTION));
157    }
158
159    #[test]
160    fn mastodon_style_mention_roundtrips() {
161        let raw = json!({
162            "type": "Mention",
163            "href": "https://mastodon.social/@alice",
164            "name": "@alice@mastodon.social"
165        });
166        let link: Link = serde_json::from_value(raw.clone()).unwrap();
167        assert_eq!(link.name.as_deref(), Some("@alice@mastodon.social"));
168        let back = serde_json::to_value(&link).unwrap();
169        assert_eq!(back, raw);
170    }
171}