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}