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}