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}