Skip to main content

webfinger_rs/types/
response.rs

1use std::collections::BTreeMap;
2use std::fmt::{self, Debug};
3
4use serde::{Deserialize, Serialize};
5use serde_with::skip_serializing_none;
6
7use crate::Error;
8use crate::{JrdUri, Link};
9
10/// A WebFinger response.
11///
12/// This is the JSON Resource Descriptor (JRD) returned by a WebFinger server. The Rust fields map
13/// directly to the top-level members from RFC 7033:
14///
15/// - [`subject`](Self::subject) is required and uses [`JrdUri`] because the RFC defines it as the
16///   URI of the resource described by the JRD.
17/// - [`aliases`](Self::aliases) is an optional list of URI strings, also represented as
18///   [`JrdUri`].
19/// - [`properties`](Self::properties) is an optional object with URI property identifiers and
20///   string-or-null values.
21/// - [`links`](Self::links) is the JRD link array. Missing `links` deserializes as an empty
22///   vector.
23///
24/// The response serializes to the RFC JSON shape. It uses typed wrappers for URI-valued and
25/// relation-valued fields while keeping builder methods string-friendly for application code.
26///
27/// See [RFC 7033 section 4.4].
28///
29/// # Examples
30///
31/// Constructing a response with builders keeps common server code concise:
32///
33/// ```rust
34/// use webfinger_rs::{Link, WebFingerResponse};
35///
36/// let avatar = Link::builder("http://webfinger.net/rel/avatar")
37///     .href("https://example.com/avatar.png")
38///     .build();
39/// let profile = Link::builder("http://webfinger.net/rel/profile-page")
40///     .href("https://example.com/profile/carol")
41///     .build();
42/// let response = WebFingerResponse::builder("acct:carol@example.com")
43///     .alias("https://example.com/profile/carol")
44///     .property("https://example.com/ns/role", "developer")
45///     .link(avatar)
46///     .link(profile)
47///     .build();
48/// ```
49///
50/// JSON `null` property values are represented with `null_property` on the builder:
51///
52/// ```rust
53/// use webfinger_rs::{Link, WebFingerResponse};
54///
55/// let response = WebFingerResponse::builder("acct:carol@example.com")
56///     .property("https://example.com/ns/role", "developer")
57///     .null_property("https://example.com/ns/previous-role")
58///     .link(
59///         Link::builder("author")
60///             .href("https://example.com/people/carol")
61///             .null_property("https://example.com/ns/legacy-page"),
62///     )
63///     .build();
64///
65/// let json = serde_json::to_value(response)?;
66/// assert_eq!(
67///     json["properties"]["https://example.com/ns/previous-role"],
68///     serde_json::Value::Null,
69/// );
70/// # Ok::<(), serde_json::Error>(())
71/// ```
72///
73/// `Response` can be used as a response in Axum handlers as it implements
74/// [`axum::response::IntoResponse`].
75///
76/// ```rust
77/// use axum::response::IntoResponse;
78/// use webfinger_rs::{Link, WebFingerRequest, WebFingerResponse};
79///
80/// async fn handler(request: WebFingerRequest) -> WebFingerResponse {
81///     // ... handle the request ...
82///     WebFingerResponse::builder("acct:carol@example.com")
83///         .alias("https://example.com/profile/carol")
84///         .property("https://example.com/ns/role", "developer")
85///         .link(
86///             Link::builder("http://webfinger.net/rel/avatar")
87///                 .href("https://example.com/avatar.png"),
88///         )
89///         .build()
90/// }
91/// ```
92///
93/// [RFC 7033 section 4.4]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4
94#[skip_serializing_none]
95#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
96pub struct Response {
97    /// The subject of the response.
98    ///
99    /// This is the URI of the resource that the JRD describes. RFC 7033 makes it required when a
100    /// response is returned, so the Rust field is not optional.
101    ///
102    /// [`JrdUri`] is used instead of `String` so relative references are rejected during
103    /// deserialization and builder construction.
104    ///
105    /// See [RFC 7033 section 4.4.1].
106    ///
107    /// [RFC 7033 section 4.4.1]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.1
108    pub subject: JrdUri,
109
110    /// The aliases of the response.
111    ///
112    /// Aliases are additional URI strings for the same subject. The field is optional because the
113    /// JSON member may be absent. Each value is a [`JrdUri`] for the same reason as
114    /// [`Response::subject`].
115    ///
116    /// See [RFC 7033 section 4.4.2].
117    ///
118    /// [RFC 7033 section 4.4.2]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.2
119    pub aliases: Option<Vec<JrdUri>>,
120
121    /// The properties of the response.
122    ///
123    /// JRD properties are a JSON object whose names are URI strings. Values may be strings or JSON
124    /// `null`, so the Rust value type is `Option<String>`. `None` serializes as a property value of
125    /// `null`; it does not omit the property from the map.
126    ///
127    /// A `BTreeMap` is used for deterministic ordering and to support the standard ordering and
128    /// hashing traits on `Response`.
129    ///
130    /// See [RFC 7033 section 4.4.3].
131    ///
132    /// [RFC 7033 section 4.4.3]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.3
133    pub properties: Option<BTreeMap<JrdUri, Option<String>>>,
134
135    /// The links of the response.
136    ///
137    /// This is the JRD `links` array. A missing JSON member deserializes to an empty vector so code
138    /// can iterate links without handling a separate absent state.
139    ///
140    /// See [RFC 7033 section 4.4.4] and [`Link`].
141    ///
142    /// [RFC 7033 section 4.4.4]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.4
143    #[serde(default)]
144    pub links: Vec<Link>,
145}
146
147impl Response {
148    /// Creates a response with the given subject and no optional JRD members.
149    ///
150    /// This constructor is intended for application-controlled subject strings. It validates the
151    /// subject as a [`JrdUri`] and panics if the string is not an absolute URI. Use
152    /// [`Response::try_builder`] when the subject comes from external input.
153    pub fn new<S: AsRef<str>>(subject: S) -> Self {
154        Self {
155            subject: JrdUri::new(subject),
156            aliases: None,
157            properties: None,
158            links: Vec::new(),
159        }
160    }
161
162    /// Creates a [`Builder`] with the given subject.
163    ///
164    /// The builder accepts strings at the API boundary and stores typed JRD values internally. This
165    /// keeps straightforward server responses concise while still producing the RFC-shaped JSON
166    /// object.
167    ///
168    /// # Examples
169    ///
170    /// ```rust
171    /// use webfinger_rs::{Link, WebFingerResponse};
172    ///
173    /// let avatar =
174    ///     Link::builder("http://webfinger.net/rel/avatar").href("https://example.com/avatar.png");
175    /// let response = WebFingerResponse::builder("acct:carol@example.com")
176    ///     .alias("https://example.com/profile/carol")
177    ///     .property("https://example.com/ns/role", "developer")
178    ///     .link(avatar)
179    ///     .build();
180    /// ```
181    pub fn builder<S: AsRef<str>>(subject: S) -> Builder {
182        Builder::new(subject)
183    }
184
185    /// Tries to create a new [`Builder`] with the given subject.
186    ///
187    /// Use this when the subject string has not already been validated by application logic. The
188    /// returned builder has the same methods as [`Response::builder`].
189    ///
190    /// # Examples
191    ///
192    /// ```rust
193    /// use webfinger_rs::WebFingerResponse;
194    ///
195    /// assert!(WebFingerResponse::try_builder("acct:carol@example.com").is_ok());
196    /// assert!(WebFingerResponse::try_builder("/users/carol").is_err());
197    /// ```
198    pub fn try_builder<S: AsRef<str>>(subject: S) -> Result<Builder, Error> {
199        Ok(Builder::new(JrdUri::try_new(subject)?))
200    }
201}
202
203impl fmt::Display for Response {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        write!(f, "{}", serde_json::to_string_pretty(self).unwrap())
206    }
207}
208
209/// A builder for a WebFinger response.
210///
211/// `Builder` constructs a [`Response`] using the JRD member names from RFC 7033. It is the
212/// preferred API for ordinary server responses because it accepts string-like values and converts
213/// them to [`JrdUri`] or [`Link`] where the stored response type is stricter than JSON text.
214///
215/// # Examples
216///
217/// ```rust
218/// use webfinger_rs::{Link, WebFingerResponse};
219///
220/// let response = WebFingerResponse::builder("acct:carol@example.com")
221///     .alias("https://example.com/users/carol")
222///     .property("https://example.com/ns/display-name", "Carol")
223///     .link(Link::builder("avatar").href("https://example.com/avatar/carol.png"))
224///     .build();
225///
226/// assert_eq!(response.subject.as_ref(), "acct:carol@example.com");
227/// ```
228#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
229pub struct Builder {
230    response: Response,
231}
232
233impl Builder {
234    /// Creates a response builder with the given subject.
235    ///
236    /// The subject is validated immediately as a [`JrdUri`].
237    pub fn new<S: AsRef<str>>(subject: S) -> Self {
238        Self {
239            response: Response::new(subject),
240        }
241    }
242
243    /// Adds an alias URI to the response.
244    ///
245    /// The value is validated as a [`JrdUri`] and serialized in the `aliases` array from
246    /// [RFC 7033 section 4.4.2].
247    ///
248    /// [RFC 7033 section 4.4.2]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.2
249    pub fn alias<S: AsRef<str>>(mut self, alias: S) -> Self {
250        self.response
251            .aliases
252            .get_or_insert_with(Vec::new)
253            .push(JrdUri::new(alias));
254        self
255    }
256
257    /// Adds a string-valued property to the response.
258    ///
259    /// The key is validated as a [`JrdUri`]. The value serializes as a JSON string under that
260    /// property identifier.
261    ///
262    /// [RFC 7033 section 4.4.3]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.3
263    pub fn property<K: AsRef<str>, V: Into<String>>(mut self, key: K, value: V) -> Self {
264        self.response
265            .properties
266            .get_or_insert_with(BTreeMap::new)
267            .insert(JrdUri::new(key), Some(value.into()));
268        self
269    }
270
271    /// Adds a null-valued property to the response.
272    ///
273    /// This writes the property with a JSON `null` value. It is different from leaving the
274    /// property out of the JRD object.
275    ///
276    /// [RFC 7033 section 4.4.3]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.3
277    pub fn null_property<K: AsRef<str>>(mut self, key: K) -> Self {
278        self.response
279            .properties
280            .get_or_insert_with(BTreeMap::new)
281            .insert(JrdUri::new(key), None);
282        self
283    }
284
285    /// Adds a link to the response.
286    ///
287    /// If the link is constructed with a builder, it is not necessary to call the `build` method on
288    /// the link as the builder implements `From<LinkBuilder> for Link`.
289    ///
290    /// This appends to the `links` array from [RFC 7033 section 4.4.4].
291    ///
292    /// [RFC 7033 section 4.4.4]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.4
293    pub fn link<L: Into<Link>>(mut self, link: L) -> Self {
294        self.response.links.push(link.into());
295        self
296    }
297
298    /// Sets the complete link array for the response.
299    ///
300    /// Use this when links are already collected. Use [`Builder::link`] when appending links one at
301    /// a time.
302    ///
303    /// [RFC 7033 section 4.4.4]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.4.4
304    pub fn links(mut self, links: Vec<Link>) -> Self {
305        self.response.links = links;
306        self
307    }
308
309    /// Builds the response.
310    pub fn build(self) -> Response {
311        self.response
312    }
313}
314
315/// Custom debug implementation to avoid printing `None` fields
316impl Debug for Builder {
317    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
318        f.debug_tuple("Builder").field(&self.response).finish()
319    }
320}
321
322/// Custom debug implementation to avoid printing `None` fields
323impl Debug for Response {
324    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
325        let mut debug = f.debug_struct("Response");
326        let mut debug = debug.field("subject", &self.subject);
327        if let Some(aliases) = &self.aliases {
328            debug = debug.field("aliases", &aliases);
329        }
330        if let Some(properties) = &self.properties {
331            debug = debug.field("properties", &properties);
332        }
333        debug.field("links", &self.links).finish()
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use std::fmt::{Debug, Display};
340    use std::hash::Hash;
341
342    use serde::{Deserialize, Serialize};
343    use serde_json::json;
344
345    use super::*;
346    use crate::Rel;
347
348    type Result<T = (), E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
349
350    fn assert_data_traits<T>()
351    where
352        T: Clone
353            + Debug
354            + Display
355            + Eq
356            + Ord
357            + Hash
358            + Send
359            + Sync
360            + Serialize
361            + for<'de> Deserialize<'de>,
362    {
363    }
364
365    fn assert_builder_traits<T>()
366    where
367        T: Clone + Debug + Eq + Ord + Hash + Send + Sync,
368    {
369    }
370
371    #[test]
372    fn implements_applicable_common_traits() {
373        assert_data_traits::<Response>();
374        assert_builder_traits::<Builder>();
375    }
376
377    #[test]
378    fn deserializes_rfc_shaped_jrd_with_null_properties_and_title_object() -> Result {
379        let json = r#"
380        {
381          "subject": "http://blog.example.com/article/id/314",
382          "aliases": [
383            "http://blog.example.com/cool_new_thing",
384            "http://blog.example.com/steve/article/7"
385          ],
386          "properties": {
387            "http://blgx.example.net/ns/version": "1.3",
388            "http://blgx.example.net/ns/ext": null
389          },
390          "links": [
391            {
392              "rel": "author",
393              "href": "http://blog.example.com/author/steve",
394              "titles": {
395                "en-us": "The Magical World of Steve",
396                "fr": "Le Monde Magique de Steve"
397              },
398              "properties": {
399                "http://example.com/role": "editor",
400                "http://example.com/old-role": null
401              }
402            }
403          ]
404        }
405        "#;
406
407        let response = serde_json::from_str::<Response>(json)?;
408        let properties = response.properties.as_ref().expect("properties");
409        let links = &response.links;
410        let link = links.first().expect("link");
411        let titles = link.titles.as_ref().expect("titles");
412        let link_properties = link.properties.as_ref().expect("link properties");
413
414        assert_eq!(
415            response.subject.as_ref(),
416            "http://blog.example.com/article/id/314"
417        );
418        assert_eq!(
419            response.aliases.as_ref().expect("aliases")[0].as_ref(),
420            "http://blog.example.com/cool_new_thing"
421        );
422        assert_eq!(
423            properties
424                .get(&JrdUri::new("http://blgx.example.net/ns/version"))
425                .expect("version")
426                .as_deref(),
427            Some("1.3")
428        );
429        assert_eq!(
430            properties.get(&JrdUri::new("http://blgx.example.net/ns/ext")),
431            Some(&None)
432        );
433        assert_eq!(link.rel, Rel::new("author"));
434        assert_eq!(
435            link.href.as_ref().expect("href").as_ref(),
436            "http://blog.example.com/author/steve"
437        );
438        assert_eq!(
439            titles.get("en-us").map(String::as_str),
440            Some("The Magical World of Steve")
441        );
442        assert_eq!(
443            link_properties.get(&JrdUri::new("http://example.com/old-role")),
444            Some(&None)
445        );
446        Ok(())
447    }
448
449    #[test]
450    fn serializes_builder_output_as_rfc_shaped_jrd() -> Result {
451        let response = Response::builder("acct:carol@example.com")
452            .alias("https://example.com/profile/carol")
453            .property("https://example.com/ns/role", "developer")
454            .null_property("https://example.com/ns/old-role")
455            .link(
456                Link::builder("http://webfinger.net/rel/profile-page")
457                    .href("https://example.com/profile/carol")
458                    .title("en-us", "Carol's Profile")
459                    .property("https://example.com/ns/verified", "true")
460                    .null_property("https://example.com/ns/legacy"),
461            )
462            .build();
463
464        let json = serde_json::to_value(response)?;
465
466        assert_eq!(
467            json,
468            json!({
469                "subject": "acct:carol@example.com",
470                "aliases": ["https://example.com/profile/carol"],
471                "properties": {
472                    "https://example.com/ns/role": "developer",
473                    "https://example.com/ns/old-role": null
474                },
475                "links": [
476                    {
477                        "rel": "http://webfinger.net/rel/profile-page",
478                        "href": "https://example.com/profile/carol",
479                        "titles": {
480                            "en-us": "Carol's Profile"
481                        },
482                        "properties": {
483                            "https://example.com/ns/verified": "true",
484                            "https://example.com/ns/legacy": null
485                        }
486                    }
487                ]
488            })
489        );
490        Ok(())
491    }
492
493    #[test]
494    fn rejects_relative_jrd_uris() {
495        let json =
496            r#"{"subject":"acct:carol@example.com","links":[{"rel":"author","href":"/carol"}]}"#;
497
498        let error = serde_json::from_str::<Response>(json).expect_err("relative href");
499
500        assert!(error.to_string().contains("invalid JRD URI"));
501    }
502
503    #[test]
504    fn rejects_empty_relation_types() {
505        let json = r#"{"subject":"acct:carol@example.com","links":[{"rel":""}]}"#;
506
507        let error = serde_json::from_str::<Response>(json).expect_err("empty rel");
508
509        assert!(error.to_string().contains("invalid relation type"));
510    }
511
512    #[test]
513    fn deserializes_jrd_without_links() -> Result {
514        let json = r#"{"subject":"acct:carol@example.com"}"#;
515
516        let response = serde_json::from_str::<Response>(json)?;
517
518        assert!(response.links.is_empty());
519        Ok(())
520    }
521}