Skip to main content

api_bones/
links.rs

1//! HATEOAS `Link` and `Links` types for hypermedia-driven API responses.
2//!
3//! A [`Link`] captures a single hypermedia relation (`rel`, `href`, optional
4//! `method`). [`Links`] is an ordered collection of [`Link`] values with
5//! helper factory methods for the most common rels.
6//!
7//! # Example
8//!
9//! ```rust
10//! use api_bones::links::{Link, Links};
11//!
12//! let links = Links::new()
13//!     .push(Link::self_link("/resources/42"))
14//!     .push(Link::next("/resources?page=2"));
15//!
16//! assert_eq!(links.find("self").unwrap().href, "/resources/42");
17//! ```
18
19#[cfg(all(not(feature = "std"), feature = "alloc"))]
20use alloc::{string::String, vec::Vec};
21#[cfg(feature = "serde")]
22use serde::{Deserialize, Serialize};
23
24// ---------------------------------------------------------------------------
25// Link
26// ---------------------------------------------------------------------------
27
28/// A single HATEOAS link with a relation type, target URL, and optional HTTP
29/// method hint.
30///
31/// The `rel` field follows the
32/// [IANA link relations registry](https://www.iana.org/assignments/link-relations/link-relations.xhtml)
33/// where applicable (e.g. `"self"`, `"next"`, `"prev"`).
34#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
36#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
37#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
38#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
39#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
40pub struct Link {
41    /// The link relation type (e.g. `"self"`, `"next"`, `"related"`).
42    pub rel: String,
43
44    /// The target URL.
45    pub href: String,
46
47    /// Optional HTTP method hint (e.g. `"GET"`, `"POST"`).
48    #[cfg_attr(
49        feature = "serde",
50        serde(default, skip_serializing_if = "Option::is_none")
51    )]
52    pub method: Option<String>,
53}
54
55impl Link {
56    /// Create a new `Link` with the given relation and href.
57    ///
58    /// ```
59    /// use api_bones::links::Link;
60    ///
61    /// let link = Link::new("related", "/other");
62    /// assert_eq!(link.rel, "related");
63    /// assert_eq!(link.href, "/other");
64    /// assert!(link.method.is_none());
65    /// ```
66    pub fn new(rel: impl Into<String>, href: impl Into<String>) -> Self {
67        Self {
68            rel: rel.into(),
69            href: href.into(),
70            method: None,
71        }
72    }
73
74    /// Set the optional HTTP method hint (builder-style).
75    ///
76    /// ```
77    /// use api_bones::links::Link;
78    ///
79    /// let link = Link::new("create", "/items").method("POST");
80    /// assert_eq!(link.method.as_deref(), Some("POST"));
81    /// ```
82    #[must_use]
83    pub fn method(mut self, method: impl Into<String>) -> Self {
84        self.method = Some(method.into());
85        self
86    }
87
88    /// Construct a `"self"` link.
89    ///
90    /// ```
91    /// use api_bones::links::Link;
92    ///
93    /// let link = Link::self_link("/resources/42");
94    /// assert_eq!(link.rel, "self");
95    /// assert_eq!(link.href, "/resources/42");
96    /// ```
97    pub fn self_link(href: impl Into<String>) -> Self {
98        Self::new("self", href)
99    }
100
101    /// Construct a `"next"` link (next page in a paginated response).
102    ///
103    /// ```
104    /// use api_bones::links::Link;
105    ///
106    /// let link = Link::next("/resources?page=2");
107    /// assert_eq!(link.rel, "next");
108    /// assert_eq!(link.href, "/resources?page=2");
109    /// ```
110    pub fn next(href: impl Into<String>) -> Self {
111        Self::new("next", href)
112    }
113
114    /// Construct a `"prev"` link (previous page in a paginated response).
115    ///
116    /// ```
117    /// use api_bones::links::Link;
118    ///
119    /// let link = Link::prev("/resources?page=1");
120    /// assert_eq!(link.rel, "prev");
121    /// assert_eq!(link.href, "/resources?page=1");
122    /// ```
123    pub fn prev(href: impl Into<String>) -> Self {
124        Self::new("prev", href)
125    }
126
127    /// Construct a `"related"` link.
128    ///
129    /// ```
130    /// use api_bones::links::Link;
131    ///
132    /// let link = Link::related("/users/42");
133    /// assert_eq!(link.rel, "related");
134    /// assert_eq!(link.href, "/users/42");
135    /// ```
136    pub fn related(href: impl Into<String>) -> Self {
137        Self::new("related", href)
138    }
139
140    /// Construct a `"first"` link (first page of a paginated response).
141    ///
142    /// ```
143    /// use api_bones::links::Link;
144    ///
145    /// let link = Link::first("/resources?page=1");
146    /// assert_eq!(link.rel, "first");
147    /// assert_eq!(link.href, "/resources?page=1");
148    /// ```
149    pub fn first(href: impl Into<String>) -> Self {
150        Self::new("first", href)
151    }
152
153    /// Construct a `"last"` link (last page of a paginated response).
154    ///
155    /// ```
156    /// use api_bones::links::Link;
157    ///
158    /// let link = Link::last("/resources?page=10");
159    /// assert_eq!(link.rel, "last");
160    /// assert_eq!(link.href, "/resources?page=10");
161    /// ```
162    pub fn last(href: impl Into<String>) -> Self {
163        Self::new("last", href)
164    }
165}
166
167// ---------------------------------------------------------------------------
168// Links
169// ---------------------------------------------------------------------------
170
171/// An ordered collection of [`Link`] values.
172///
173/// Preserves insertion order; duplicate `rel` values are allowed (some APIs
174/// return multiple `"related"` links).  Use [`Links::find`] to look up the
175/// first link with a given `rel`.
176#[derive(Debug, Clone, PartialEq, Eq, Default)]
177#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
178#[cfg_attr(feature = "serde", serde(transparent))]
179#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
180#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
181#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
182#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
183pub struct Links(Vec<Link>);
184
185impl Links {
186    /// Create an empty `Links` collection.
187    ///
188    /// ```
189    /// use api_bones::links::Links;
190    ///
191    /// let links = Links::new();
192    /// assert!(links.is_empty());
193    /// ```
194    #[must_use]
195    pub fn new() -> Self {
196        Self::default()
197    }
198
199    /// Append a [`Link`] and return `self` (builder-style).
200    ///
201    /// ```
202    /// use api_bones::links::{Link, Links};
203    ///
204    /// let links = Links::new()
205    ///     .push(Link::self_link("/a"))
206    ///     .push(Link::next("/b"));
207    /// assert_eq!(links.len(), 2);
208    /// ```
209    #[must_use]
210    pub fn push(mut self, link: Link) -> Self {
211        self.0.push(link);
212        self
213    }
214
215    /// Return the first [`Link`] whose `rel` matches `rel`, if any.
216    ///
217    /// ```
218    /// use api_bones::links::{Link, Links};
219    ///
220    /// let links = Links::new()
221    ///     .push(Link::self_link("/a"))
222    ///     .push(Link::next("/b"));
223    /// assert_eq!(links.find("next").unwrap().href, "/b");
224    /// assert!(links.find("prev").is_none());
225    /// ```
226    #[must_use]
227    pub fn find(&self, rel: &str) -> Option<&Link> {
228        self.0.iter().find(|l| l.rel == rel)
229    }
230
231    /// Iterate over all contained links.
232    pub fn iter(&self) -> impl Iterator<Item = &Link> {
233        self.0.iter()
234    }
235
236    /// Return the number of links in the collection.
237    #[must_use]
238    pub fn len(&self) -> usize {
239        self.0.len()
240    }
241
242    /// Return `true` if the collection contains no links.
243    #[must_use]
244    pub fn is_empty(&self) -> bool {
245        self.0.is_empty()
246    }
247}
248
249impl From<Vec<Link>> for Links {
250    fn from(v: Vec<Link>) -> Self {
251        Self(v)
252    }
253}
254
255impl IntoIterator for Links {
256    type Item = Link;
257    type IntoIter = <Vec<Link> as IntoIterator>::IntoIter;
258
259    fn into_iter(self) -> Self::IntoIter {
260        self.0.into_iter()
261    }
262}
263
264// ---------------------------------------------------------------------------
265// Tests
266// ---------------------------------------------------------------------------
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    // -----------------------------------------------------------------------
273    // Link construction
274    // -----------------------------------------------------------------------
275
276    #[test]
277    fn link_new() {
278        let l = Link::new("self", "/foo");
279        assert_eq!(l.rel, "self");
280        assert_eq!(l.href, "/foo");
281        assert!(l.method.is_none());
282    }
283
284    #[test]
285    fn link_with_method() {
286        let l = Link::new("create", "/items").method("POST");
287        assert_eq!(l.method.as_deref(), Some("POST"));
288    }
289
290    #[test]
291    fn link_self_link_factory() {
292        let l = Link::self_link("/resources/1");
293        assert_eq!(l.rel, "self");
294        assert_eq!(l.href, "/resources/1");
295    }
296
297    #[test]
298    fn link_next_factory() {
299        let l = Link::next("/resources?page=2");
300        assert_eq!(l.rel, "next");
301    }
302
303    #[test]
304    fn link_prev_factory() {
305        let l = Link::prev("/resources?page=0");
306        assert_eq!(l.rel, "prev");
307    }
308
309    #[test]
310    fn link_related_factory() {
311        let l = Link::related("/other");
312        assert_eq!(l.rel, "related");
313    }
314
315    #[test]
316    fn link_first_factory() {
317        let l = Link::first("/resources?page=1");
318        assert_eq!(l.rel, "first");
319    }
320
321    #[test]
322    fn link_last_factory() {
323        let l = Link::last("/resources?page=10");
324        assert_eq!(l.rel, "last");
325    }
326
327    // -----------------------------------------------------------------------
328    // Links collection
329    // -----------------------------------------------------------------------
330
331    #[test]
332    fn links_new_is_empty() {
333        let links = Links::new();
334        assert!(links.is_empty());
335        assert_eq!(links.len(), 0);
336    }
337
338    #[test]
339    fn links_push_and_len() {
340        let links = Links::new()
341            .push(Link::self_link("/a"))
342            .push(Link::next("/b"));
343        assert_eq!(links.len(), 2);
344        assert!(!links.is_empty());
345    }
346
347    #[test]
348    fn links_find_hit() {
349        let links = Links::new()
350            .push(Link::self_link("/a"))
351            .push(Link::next("/b"));
352        let found = links.find("next").unwrap();
353        assert_eq!(found.href, "/b");
354    }
355
356    #[test]
357    fn links_find_miss() {
358        let links = Links::new().push(Link::self_link("/a"));
359        assert!(links.find("prev").is_none());
360    }
361
362    #[test]
363    fn links_find_returns_first_match() {
364        let links = Links::new()
365            .push(Link::related("/x"))
366            .push(Link::related("/y"));
367        assert_eq!(links.find("related").unwrap().href, "/x");
368    }
369
370    #[test]
371    fn links_iter() {
372        let links = Links::new()
373            .push(Link::self_link("/a"))
374            .push(Link::next("/b"));
375        let hrefs: Vec<&str> = links.iter().map(|l| l.href.as_str()).collect();
376        assert_eq!(hrefs, vec!["/a", "/b"]);
377    }
378
379    #[test]
380    fn links_into_iterator() {
381        let links = Links::new().push(Link::self_link("/a"));
382        assert_eq!(links.into_iter().count(), 1);
383    }
384
385    #[test]
386    fn links_from_vec() {
387        let v = vec![Link::self_link("/a"), Link::next("/b")];
388        let links = Links::from(v);
389        assert_eq!(links.len(), 2);
390    }
391
392    // -----------------------------------------------------------------------
393    // Serde round-trips
394    // -----------------------------------------------------------------------
395
396    #[cfg(feature = "serde")]
397    #[test]
398    fn link_serde_round_trip_without_method() {
399        let l = Link::self_link("/resources/1");
400        let json = serde_json::to_value(&l).unwrap();
401        assert_eq!(json["rel"], "self");
402        assert_eq!(json["href"], "/resources/1");
403        assert!(json.get("method").is_none());
404        let back: Link = serde_json::from_value(json).unwrap();
405        assert_eq!(back, l);
406    }
407
408    #[cfg(feature = "serde")]
409    #[test]
410    fn link_serde_round_trip_with_method() {
411        let l = Link::new("create", "/items").method("POST");
412        let json = serde_json::to_value(&l).unwrap();
413        assert_eq!(json["method"], "POST");
414        let back: Link = serde_json::from_value(json).unwrap();
415        assert_eq!(back, l);
416    }
417
418    #[cfg(feature = "serde")]
419    #[test]
420    fn links_serde_round_trip() {
421        let links = Links::new()
422            .push(Link::self_link("/a"))
423            .push(Link::next("/b"));
424        let json = serde_json::to_value(&links).unwrap();
425        // transparent: serializes as an array
426        assert!(json.is_array());
427        assert_eq!(json[0]["rel"], "self");
428        assert_eq!(json[1]["rel"], "next");
429        let back: Links = serde_json::from_value(json).unwrap();
430        assert_eq!(back, links);
431    }
432}