Skip to main content

webfinger_rs/types/
link.rs

1use std::collections::BTreeMap;
2use std::fmt::Debug;
3
4use serde::{Deserialize, Serialize};
5use serde_with::skip_serializing_none;
6
7use crate::{JrdUri, Rel};
8
9/// A link in the WebFinger response.
10///
11/// Link objects describe related resources for the JRD subject. RFC 7033 gives each link a
12/// required [`rel`](Self::rel) member and optional `type`, `href`, `titles`, and `properties`
13/// members. Some WebFinger profiles also use the JRD `template` member from RFC 6415 link
14/// templates.
15///
16/// The Rust fields mirror the JRD JSON shape:
17///
18/// - [`rel`](Self::rel) is a [`Rel`] so the required relation string is validated as one relation
19///   type.
20/// - [`href`](Self::href) is a [`JrdUri`] because RFC 7033 defines it as a URI string.
21/// - [`template`](Self::template) is a URI template string.
22/// - [`titles`](Self::titles) is a language-keyed object, matching the RFC JSON form.
23/// - [`properties`](Self::properties) uses [`JrdUri`] keys and `Option<String>` values so JSON
24///   `null` is representable.
25///
26/// Use [`Link::builder`] for ordinary construction from string literals or application values. Use
27/// [`Link::new`] when you already have a validated [`Rel`].
28///
29/// See [RFC 7033 section 4.4.4].
30///
31/// # Examples
32///
33/// ```rust
34/// use webfinger_rs::Link;
35///
36/// let link = Link::builder("http://webfinger.net/rel/profile-page")
37///     .href("https://example.com/profile/carol")
38///     .r#type("text/html")
39///     .title("en-us", "Carol's profile")
40///     .property("https://example.com/ns/verified", "true")
41///     .null_property("https://example.com/ns/old-profile")
42///     .build();
43///
44/// assert_eq!(link.rel.as_ref(), "http://webfinger.net/rel/profile-page");
45/// ```
46///
47/// [RFC 7033 section 4.4.4]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.4
48#[skip_serializing_none]
49#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
50pub struct Link {
51    /// The relation type of the link.
52    ///
53    /// This member is required by [RFC 7033 section 4.4.4.1]. It uses [`Rel`] instead of `String`
54    /// so deserialization and builder construction both reject empty or malformed relation
55    /// values.
56    ///
57    /// [RFC 7033 section 4.4.4.1]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.4.1
58    pub rel: Rel,
59
60    /// The media type of the link.
61    ///
62    /// RFC 7033 leaves this as a media type string. The crate stores it as `String` because it is
63    /// advisory metadata for the linked representation, not one of the WebFinger URI-valued
64    /// fields.
65    ///
66    /// See [RFC 7033 section 4.4.4.2].
67    ///
68    /// [RFC 7033 section 4.4.4.2]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.4.2
69    pub r#type: Option<String>,
70
71    /// The target URI of the link.
72    ///
73    /// RFC 7033 defines `href` as a URI string. The field uses [`JrdUri`] rather than `String` so
74    /// relative references are rejected when links are deserialized or built through the builder.
75    ///
76    /// See [RFC 7033 section 4.4.4.3].
77    ///
78    /// [RFC 7033 section 4.4.4.3]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.4.3
79    pub href: Option<JrdUri>,
80
81    /// A URI template for the link.
82    ///
83    /// RFC 6415 defines `template` as an optional JRD link member for link templates. The crate
84    /// stores it as a string because WebFinger servers do not need to parse or expand the template
85    /// expression before serializing it.
86    ///
87    /// See [RFC 6415 appendix A].
88    ///
89    /// [RFC 6415 appendix A]: https://www.rfc-editor.org/rfc/rfc6415.html#appendix-A
90    pub template: Option<String>,
91
92    /// The titles of the link.
93    ///
94    /// RFC 7033 models titles as a JSON object whose keys are language tags and whose values are
95    /// title strings. The crate uses a `BTreeMap` so direct struct construction preserves that JSON
96    /// object shape and gets deterministic ordering for comparisons, hashing, and rendered output.
97    ///
98    /// Use [`LinkBuilder::title`] for one title at a time or [`LinkBuilder::titles`] to set a full
99    /// language map.
100    ///
101    /// See [RFC 7033 section 4.4.4.4].
102    ///
103    /// [RFC 7033 section 4.4.4.4]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.4.4
104    pub titles: Option<BTreeMap<String, String>>,
105
106    /// The properties of the link.
107    ///
108    /// Link properties are a JSON object whose property identifiers are URI strings. Values may be
109    /// strings or JSON `null`, so the Rust value type is `Option<String>`. `None` serializes as a
110    /// property value of `null`; it does not omit the property from the map.
111    ///
112    /// Use [`LinkBuilder::property`] for string-valued properties and
113    /// [`LinkBuilder::null_property`] for JSON `null` values.
114    ///
115    /// See [RFC 7033 section 4.4.4.5].
116    ///
117    /// [RFC 7033 section 4.4.4.5]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.4.5
118    pub properties: Option<BTreeMap<JrdUri, Option<String>>>,
119}
120
121impl Link {
122    /// Creates a link from an already validated relation type.
123    ///
124    /// The returned link has no optional members set. This is useful when relation validation
125    /// happens separately, for example when reusing a [`Rel`] from a request filter. Use
126    /// [`Link::builder`] when constructing a link directly from strings.
127    pub fn new(rel: Rel) -> Self {
128        Self {
129            rel,
130            r#type: None,
131            href: None,
132            template: None,
133            titles: None,
134            properties: None,
135        }
136    }
137
138    /// Creates a [`LinkBuilder`] with the given relation type.
139    ///
140    /// The builder accepts a string-like value for the common case and validates it into [`Rel`].
141    /// Invalid values panic through [`Rel::new`]; use [`Rel::try_new`] and [`Link::new`] when the
142    /// relation comes from untrusted input.
143    pub fn builder<R: AsRef<str>>(rel: R) -> LinkBuilder {
144        LinkBuilder::new(rel)
145    }
146}
147
148/// A builder for a WebFinger link.
149///
150/// `LinkBuilder` keeps common JRD construction concise while preserving the typed representation
151/// used by [`Link`]. String arguments are accepted at the method boundary and converted into
152/// [`Rel`] or [`JrdUri`] where the RFC requires those shapes.
153///
154/// The builder can be passed directly to [`ResponseBuilder::link`](crate::ResponseBuilder::link)
155/// because `Link` implements `From<LinkBuilder>`.
156///
157/// # Examples
158///
159/// ```rust
160/// use webfinger_rs::{Link, WebFingerResponse};
161///
162/// let response = WebFingerResponse::builder("acct:carol@example.com")
163///     .link(
164///         Link::builder("author")
165///             .href("https://example.com/people/carol")
166///             .title("en-us", "Carol"),
167///     )
168///     .build();
169///
170/// assert_eq!(response.links[0].rel.as_ref(), "author");
171/// ```
172#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
173pub struct LinkBuilder {
174    link: Link,
175}
176
177impl LinkBuilder {
178    /// Creates a link builder with the given relation type.
179    ///
180    /// The relation is validated immediately. This catches invalid builder input before the
181    /// response is serialized.
182    pub fn new<R: AsRef<str>>(rel: R) -> Self {
183        Self {
184            link: Link::new(Rel::new(rel)),
185        }
186    }
187
188    /// Sets the media type of the link.
189    ///
190    /// This writes the optional `type` member from [RFC 7033 section 4.4.4.2].
191    ///
192    /// [RFC 7033 section 4.4.4.2]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.4.2
193    pub fn r#type<S: Into<String>>(mut self, r#type: S) -> Self {
194        self.link.r#type = Some(r#type.into());
195        self
196    }
197
198    /// Sets the target URI of the link.
199    ///
200    /// The value is validated as a [`JrdUri`] and serialized as the optional `href` member from
201    /// [RFC 7033 section 4.4.4.3].
202    ///
203    /// [RFC 7033 section 4.4.4.3]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.4.3
204    pub fn href<S: AsRef<str>>(mut self, href: S) -> Self {
205        self.link.href = Some(JrdUri::new(href));
206        self
207    }
208
209    /// Sets a URI template for the link.
210    ///
211    /// This writes the optional JRD `template` member from [RFC 6415 appendix A].
212    ///
213    /// [RFC 6415 appendix A]: https://www.rfc-editor.org/rfc/rfc6415.html#appendix-A
214    pub fn template<S: Into<String>>(mut self, template: S) -> Self {
215        self.link.template = Some(template.into());
216        self
217    }
218
219    /// Adds a single localized title to the link.
220    ///
221    /// RFC 7033 serializes titles as an object keyed by language tag, so repeated calls insert or
222    /// replace entries in that object.
223    ///
224    /// [RFC 7033 section 4.4.4.4]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.4.4
225    pub fn title<L: Into<String>, V: Into<String>>(mut self, language: L, value: V) -> Self {
226        let title = Title::new(language, value);
227        self.link
228            .titles
229            .get_or_insert_with(BTreeMap::new)
230            .insert(title.language, title.value);
231        self
232    }
233
234    /// Sets the complete language-keyed title object for the link.
235    ///
236    /// The argument can be any owned iterator of `(language, title)` pairs, including a moved
237    /// `BTreeMap` or `HashMap`. Keys and values are converted into owned strings and stored as the
238    /// JSON object described by [RFC 7033 section 4.4.4.4].
239    ///
240    /// [RFC 7033 section 4.4.4.4]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.4.4
241    pub fn titles<I, L, V>(mut self, titles: I) -> Self
242    where
243        I: IntoIterator<Item = (L, V)>,
244        L: Into<String>,
245        V: Into<String>,
246    {
247        let titles = titles
248            .into_iter()
249            .map(|(language, value)| (language.into(), value.into()))
250            .collect();
251        self.link.titles = Some(titles);
252        self
253    }
254
255    /// Adds a string-valued property to the link.
256    ///
257    /// The property identifier is validated as a [`JrdUri`]. The value serializes as a JSON string
258    /// under that property key.
259    ///
260    /// [RFC 7033 section 4.4.4.5]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.4.5
261    pub fn property<K: AsRef<str>, V: Into<String>>(mut self, key: K, value: V) -> Self {
262        self.link
263            .properties
264            .get_or_insert_with(BTreeMap::new)
265            .insert(JrdUri::new(key), Some(value.into()));
266        self
267    }
268
269    /// Adds a null-valued property to the link.
270    ///
271    /// This writes the property with a JSON `null` value. It is different from leaving the
272    /// property out of the map.
273    ///
274    /// [RFC 7033 section 4.4.4.5]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.4.5
275    pub fn null_property<K: AsRef<str>>(mut self, key: K) -> Self {
276        self.link
277            .properties
278            .get_or_insert_with(BTreeMap::new)
279            .insert(JrdUri::new(key), None);
280        self
281    }
282
283    /// Sets the complete property object for the link.
284    ///
285    /// The argument can be any owned iterator of `(JrdUri, Option<String>)` pairs. Use `Some` for
286    /// string-valued properties and `None` for JSON `null` values.
287    ///
288    /// [RFC 7033 section 4.4.4.5]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.4.5
289    pub fn properties<I>(mut self, properties: I) -> Self
290    where
291        I: IntoIterator<Item = (JrdUri, Option<String>)>,
292    {
293        self.link.properties = Some(properties.into_iter().collect());
294        self
295    }
296
297    /// Builds the link.
298    ///
299    /// This can be omitted if the link is being converted to a `Link` directly from the builder as
300    /// `LinkBuilder` also implements `From<LinkBuilder> for Link`.
301    pub fn build(self) -> Link {
302        self.link
303    }
304}
305
306impl From<LinkBuilder> for Link {
307    fn from(builder: LinkBuilder) -> Self {
308        builder.build()
309    }
310}
311
312/// Custom debug implementation to avoid printing `None` fields
313impl Debug for LinkBuilder {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        f.debug_tuple("LinkBuilder").field(&self.link).finish()
316    }
317}
318
319/// Custom debug implementation to avoid printing `None` fields
320impl Debug for Link {
321    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
322        let mut debug = f.debug_struct("Link");
323        let mut debug = debug.field("rel", &self.rel);
324        if let Some(r#type) = &self.r#type {
325            debug = debug.field("type", &r#type);
326        }
327        if let Some(href) = &self.href {
328            debug = debug.field("href", &href);
329        }
330        if let Some(template) = &self.template {
331            debug = debug.field("template", &template);
332        }
333        if let Some(titles) = &self.titles {
334            debug = debug.field("titles", &titles);
335        }
336        if let Some(properties) = &self.properties {
337            debug = debug.field("properties", &properties);
338        }
339        debug.finish()
340    }
341}
342
343/// A title in the WebFinger response.
344///
345/// RFC 7033 serializes titles as a JSON object, not as a list of title objects. `Title` is a small
346/// helper for builder-style construction where a caller wants to name one `(language, value)` pair
347/// before it is inserted into the link's language-keyed map.
348///
349/// The language is stored as `String` because RFC 7033 points at language tags but does not require
350/// WebFinger implementations to enforce a particular registry or normalization policy here.
351///
352/// See [RFC 7033 section 4.4.4.4].
353///
354/// # Examples
355///
356/// ```rust
357/// use webfinger_rs::{Link, Title};
358///
359/// let title = Title::new("en-us", "Carol's Profile");
360/// let link = Link::builder("http://webfinger.net/rel/profile-page")
361///     .title(title.language, title.value)
362///     .build();
363///
364/// assert_eq!(
365///     link.titles.unwrap().get("en-us").map(String::as_str),
366///     Some("Carol's Profile"),
367/// );
368/// ```
369///
370/// [RFC 7033 section 4.4.4.4]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.4.4
371#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
372pub struct Title {
373    /// The language of the title.
374    ///
375    /// This can be any valid language tag as defined in [RFC
376    /// 5646](https://www.rfc-editor.org/rfc/rfc5646.html) or the string `und` to indicate an
377    /// undefined language.
378    pub language: String,
379    /// The title text for this language.
380    pub value: String,
381}
382
383impl Title {
384    /// Creates a title pair with the given language and value.
385    pub fn new<L: Into<String>, V: Into<String>>(language: L, value: V) -> Self {
386        Self {
387            language: language.into(),
388            value: value.into(),
389        }
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use std::fmt::Debug;
396    use std::hash::Hash;
397
398    use serde::{Deserialize, Serialize};
399    use serde_json::json;
400
401    use super::*;
402
403    type Result<T = (), E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
404
405    fn assert_data_traits<T>()
406    where
407        T: Clone + Debug + Eq + Ord + Hash + Send + Sync + Serialize + for<'de> Deserialize<'de>,
408    {
409    }
410
411    fn assert_ordered_value_traits<T>()
412    where
413        T: Clone + Debug + Eq + Ord + Hash + Send + Sync + Serialize + for<'de> Deserialize<'de>,
414    {
415    }
416
417    fn assert_builder_traits<T>()
418    where
419        T: Clone + Debug + Eq + Ord + Hash + Send + Sync,
420    {
421    }
422
423    #[test]
424    fn implements_applicable_common_traits() {
425        assert_data_traits::<Link>();
426        assert_ordered_value_traits::<Title>();
427        assert_builder_traits::<LinkBuilder>();
428    }
429
430    #[test]
431    fn builder_serializes_titles_as_language_object() -> Result {
432        let link = Link::builder("http://webfinger.net/rel/profile-page")
433            .href("https://example.com/profile/carol")
434            .title("en-us", "Carol's Profile")
435            .build();
436
437        let json = serde_json::to_value(link)?;
438
439        assert_eq!(
440            json,
441            json!({
442                "rel": "http://webfinger.net/rel/profile-page",
443                "href": "https://example.com/profile/carol",
444                "titles": {
445                    "en-us": "Carol's Profile"
446                }
447            })
448        );
449        Ok(())
450    }
451
452    #[test]
453    fn builder_serializes_template() -> Result {
454        let link = Link::builder("http://ostatus.org/schema/1.0/subscribe")
455            .template("https://example.com/authorize_interaction?uri={uri}")
456            .build();
457
458        let json = serde_json::to_value(link)?;
459
460        assert_eq!(
461            json,
462            json!({
463                "rel": "http://ostatus.org/schema/1.0/subscribe",
464                "template": "https://example.com/authorize_interaction?uri={uri}",
465            })
466        );
467        Ok(())
468    }
469
470    #[test]
471    fn deserializes_template() -> Result {
472        let json = r#"
473        {
474          "rel": "copyright",
475          "template": "http://example.com/copyright?id={uri}"
476        }
477        "#;
478
479        let link: Link = serde_json::from_str(json)?;
480
481        assert_eq!(link.rel.as_ref(), "copyright");
482        assert_eq!(
483            link.template.as_deref(),
484            Some("http://example.com/copyright?id={uri}")
485        );
486        Ok(())
487    }
488
489    #[test]
490    fn builder_serializes_null_properties() -> Result {
491        let link = Link::builder("author")
492            .property("https://example.com/ns/role", "editor")
493            .null_property("https://example.com/ns/old-role")
494            .build();
495
496        let json = serde_json::to_value(link)?;
497
498        assert_eq!(
499            json,
500            json!({
501                "rel": "author",
502                "properties": {
503                    "https://example.com/ns/role": "editor",
504                    "https://example.com/ns/old-role": null
505                }
506            })
507        );
508        Ok(())
509    }
510
511    #[test]
512    fn deserialization_rejects_title_array_shape() {
513        let json = r#"
514        {
515          "rel": "author",
516          "titles": [
517            {
518              "language": "en-us",
519              "value": "Carol"
520            }
521          ]
522        }
523        "#;
524
525        let error = serde_json::from_str::<Link>(json).expect_err("title array");
526
527        assert!(error.to_string().contains("invalid type"));
528    }
529
530    #[test]
531    fn deserialization_rejects_relative_property_identifiers() {
532        let json = r#"
533        {
534          "rel": "author",
535          "properties": {
536            "/ns/role": "editor"
537          }
538        }
539        "#;
540
541        let error = serde_json::from_str::<Link>(json).expect_err("relative property identifier");
542
543        assert!(error.to_string().contains("invalid JRD URI"));
544    }
545}