rustapi_core/
hateoas.rs

1//! HATEOAS (Hypermedia As The Engine Of Application State) support
2//!
3//! This module provides hypermedia link support for REST APIs following
4//! the HAL (Hypertext Application Language) specification.
5//!
6//! # Overview
7//!
8//! HATEOAS enables REST APIs to provide navigation links in responses,
9//! making APIs more discoverable and self-documenting.
10//!
11//! # Example
12//!
13//! ```rust,ignore
14//! use rustapi_core::hateoas::{Resource, Link};
15//!
16//! #[derive(Serialize)]
17//! struct User {
18//!     id: i64,
19//!     name: String,
20//! }
21//!
22//! async fn get_user(Path(id): Path<i64>) -> Json<Resource<User>> {
23//!     let user = User { id, name: "John".to_string() };
24//!     
25//!     Json(Resource::new(user)
26//!         .self_link(&format!("/users/{}", id))
27//!         .link("orders", &format!("/users/{}/orders", id))
28//!         .link("profile", &format!("/users/{}/profile", id)))
29//! }
30//! ```
31
32use serde::{Deserialize, Serialize};
33use std::collections::HashMap;
34
35/// A hypermedia link following HAL specification
36///
37/// Links provide navigation between related resources.
38///
39/// # Example
40/// ```rust
41/// use rustapi_core::hateoas::Link;
42///
43/// let link = Link::new("/users/123")
44///     .title("User details")
45///     .set_templated(false);
46/// ```
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct Link {
49    /// The URI of the linked resource
50    pub href: String,
51
52    /// Whether the href is a URI template
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub templated: Option<bool>,
55
56    /// Human-readable title for the link
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub title: Option<String>,
59
60    /// Media type hint for the linked resource
61    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
62    pub media_type: Option<String>,
63
64    /// URI indicating the link is deprecated
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub deprecation: Option<String>,
67
68    /// Name for differentiating links with the same relation
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub name: Option<String>,
71
72    /// URI of a profile document
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub profile: Option<String>,
75
76    /// Content-Language of the linked resource
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub hreflang: Option<String>,
79}
80
81impl Link {
82    /// Create a new link with the given href
83    pub fn new(href: impl Into<String>) -> Self {
84        Self {
85            href: href.into(),
86            templated: None,
87            title: None,
88            media_type: None,
89            deprecation: None,
90            name: None,
91            profile: None,
92            hreflang: None,
93        }
94    }
95
96    /// Create a templated link (URI template)
97    ///
98    /// # Example
99    /// ```rust
100    /// use rustapi_core::hateoas::Link;
101    ///
102    /// let link = Link::templated("/users/{id}");
103    /// ```
104    pub fn templated(href: impl Into<String>) -> Self {
105        Self {
106            href: href.into(),
107            templated: Some(true),
108            ..Self::new("")
109        }
110    }
111
112    /// Set whether this link is templated
113    pub fn set_templated(mut self, templated: bool) -> Self {
114        self.templated = Some(templated);
115        self
116    }
117
118    /// Set the title
119    pub fn title(mut self, title: impl Into<String>) -> Self {
120        self.title = Some(title.into());
121        self
122    }
123
124    /// Set the media type
125    pub fn media_type(mut self, media_type: impl Into<String>) -> Self {
126        self.media_type = Some(media_type.into());
127        self
128    }
129
130    /// Mark as deprecated
131    pub fn deprecation(mut self, deprecation_url: impl Into<String>) -> Self {
132        self.deprecation = Some(deprecation_url.into());
133        self
134    }
135
136    /// Set the name
137    pub fn name(mut self, name: impl Into<String>) -> Self {
138        self.name = Some(name.into());
139        self
140    }
141
142    /// Set the profile
143    pub fn profile(mut self, profile: impl Into<String>) -> Self {
144        self.profile = Some(profile.into());
145        self
146    }
147
148    /// Set the hreflang
149    pub fn hreflang(mut self, hreflang: impl Into<String>) -> Self {
150        self.hreflang = Some(hreflang.into());
151        self
152    }
153}
154
155/// Resource wrapper with HATEOAS links (HAL format)
156///
157/// Wraps any data type with `_links` and optional `_embedded` sections.
158///
159/// # Example
160/// ```rust
161/// use rustapi_core::hateoas::Resource;
162/// use serde::Serialize;
163///
164/// #[derive(Serialize)]
165/// struct User {
166///     id: i64,
167///     name: String,
168/// }
169///
170/// let user = User { id: 1, name: "John".to_string() };
171/// let resource = Resource::new(user)
172///     .self_link("/users/1")
173///     .link("orders", "/users/1/orders");
174/// ```
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct Resource<T> {
177    /// The actual resource data (flattened into the JSON)
178    #[serde(flatten)]
179    pub data: T,
180
181    /// Hypermedia links
182    #[serde(rename = "_links")]
183    pub links: HashMap<String, LinkOrArray>,
184
185    /// Embedded resources
186    #[serde(rename = "_embedded", skip_serializing_if = "Option::is_none")]
187    pub embedded: Option<HashMap<String, serde_json::Value>>,
188}
189
190/// Either a single link or an array of links
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
192#[serde(untagged)]
193pub enum LinkOrArray {
194    /// Single link
195    Single(Link),
196    /// Array of links (for multiple links with same relation)
197    Array(Vec<Link>),
198}
199
200impl From<Link> for LinkOrArray {
201    fn from(link: Link) -> Self {
202        LinkOrArray::Single(link)
203    }
204}
205
206impl From<Vec<Link>> for LinkOrArray {
207    fn from(links: Vec<Link>) -> Self {
208        LinkOrArray::Array(links)
209    }
210}
211
212impl<T> Resource<T> {
213    /// Create a new resource wrapper
214    pub fn new(data: T) -> Self {
215        Self {
216            data,
217            links: HashMap::new(),
218            embedded: None,
219        }
220    }
221
222    /// Add a link with the given relation
223    pub fn link(mut self, rel: impl Into<String>, href: impl Into<String>) -> Self {
224        self.links
225            .insert(rel.into(), LinkOrArray::Single(Link::new(href)));
226        self
227    }
228
229    /// Add a link object
230    pub fn link_object(mut self, rel: impl Into<String>, link: Link) -> Self {
231        self.links.insert(rel.into(), LinkOrArray::Single(link));
232        self
233    }
234
235    /// Add multiple links for the same relation
236    pub fn links(mut self, rel: impl Into<String>, links: Vec<Link>) -> Self {
237        self.links.insert(rel.into(), LinkOrArray::Array(links));
238        self
239    }
240
241    /// Add the canonical self link
242    pub fn self_link(self, href: impl Into<String>) -> Self {
243        self.link("self", href)
244    }
245
246    /// Add embedded resources
247    pub fn embed<E: Serialize>(
248        mut self,
249        rel: impl Into<String>,
250        resources: E,
251    ) -> Result<Self, serde_json::Error> {
252        let embedded = self.embedded.get_or_insert_with(HashMap::new);
253        embedded.insert(rel.into(), serde_json::to_value(resources)?);
254        Ok(self)
255    }
256
257    /// Add pre-serialized embedded resources
258    pub fn embed_raw(mut self, rel: impl Into<String>, value: serde_json::Value) -> Self {
259        let embedded = self.embedded.get_or_insert_with(HashMap::new);
260        embedded.insert(rel.into(), value);
261        self
262    }
263}
264
265/// Collection of resources with pagination support
266///
267/// Provides a standardized way to return paginated collections with
268/// navigation links.
269///
270/// # Example
271/// ```rust
272/// use rustapi_core::hateoas::{ResourceCollection, PageInfo};
273/// use serde::Serialize;
274///
275/// #[derive(Serialize, Clone)]
276/// struct User {
277///     id: i64,
278///     name: String,
279/// }
280///
281/// let users = vec![
282///     User { id: 1, name: "John".to_string() },
283///     User { id: 2, name: "Jane".to_string() },
284/// ];
285///
286/// let collection = ResourceCollection::new("users", users)
287///     .self_link("/users?page=1")
288///     .next_link("/users?page=2")
289///     .page_info(PageInfo::new(20, 100, 5, 1));
290/// ```
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct ResourceCollection<T> {
293    /// Embedded resources
294    #[serde(rename = "_embedded")]
295    pub embedded: HashMap<String, Vec<T>>,
296
297    /// Navigation links
298    #[serde(rename = "_links")]
299    pub links: HashMap<String, LinkOrArray>,
300
301    /// Pagination information
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub page: Option<PageInfo>,
304}
305
306/// Pagination information
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct PageInfo {
309    /// Number of items per page
310    pub size: usize,
311    /// Total number of items
312    #[serde(rename = "totalElements")]
313    pub total_elements: usize,
314    /// Total number of pages
315    #[serde(rename = "totalPages")]
316    pub total_pages: usize,
317    /// Current page number (0-indexed)
318    pub number: usize,
319}
320
321impl PageInfo {
322    /// Create new page info
323    pub fn new(size: usize, total_elements: usize, total_pages: usize, number: usize) -> Self {
324        Self {
325            size,
326            total_elements,
327            total_pages,
328            number,
329        }
330    }
331
332    /// Calculate page info from total elements and page size
333    pub fn calculate(total_elements: usize, page_size: usize, current_page: usize) -> Self {
334        let total_pages = total_elements.div_ceil(page_size);
335        Self {
336            size: page_size,
337            total_elements,
338            total_pages,
339            number: current_page,
340        }
341    }
342}
343
344impl<T> ResourceCollection<T> {
345    /// Create a new resource collection
346    pub fn new(rel: impl Into<String>, items: Vec<T>) -> Self {
347        let mut embedded = HashMap::new();
348        embedded.insert(rel.into(), items);
349
350        Self {
351            embedded,
352            links: HashMap::new(),
353            page: None,
354        }
355    }
356
357    /// Add a link
358    pub fn link(mut self, rel: impl Into<String>, href: impl Into<String>) -> Self {
359        self.links
360            .insert(rel.into(), LinkOrArray::Single(Link::new(href)));
361        self
362    }
363
364    /// Add self link
365    pub fn self_link(self, href: impl Into<String>) -> Self {
366        self.link("self", href)
367    }
368
369    /// Add first page link
370    pub fn first_link(self, href: impl Into<String>) -> Self {
371        self.link("first", href)
372    }
373
374    /// Add last page link
375    pub fn last_link(self, href: impl Into<String>) -> Self {
376        self.link("last", href)
377    }
378
379    /// Add next page link
380    pub fn next_link(self, href: impl Into<String>) -> Self {
381        self.link("next", href)
382    }
383
384    /// Add previous page link
385    pub fn prev_link(self, href: impl Into<String>) -> Self {
386        self.link("prev", href)
387    }
388
389    /// Set page info
390    pub fn page_info(mut self, page: PageInfo) -> Self {
391        self.page = Some(page);
392        self
393    }
394
395    /// Build pagination links from page info
396    pub fn with_pagination(mut self, base_url: &str) -> Self {
397        // Clone page info to avoid borrow issues
398        let page_info = self.page.clone();
399
400        if let Some(page) = page_info {
401            self = self.self_link(format!(
402                "{}?page={}&size={}",
403                base_url, page.number, page.size
404            ));
405            self = self.first_link(format!("{}?page=0&size={}", base_url, page.size));
406
407            if page.total_pages > 0 {
408                self = self.last_link(format!(
409                    "{}?page={}&size={}",
410                    base_url,
411                    page.total_pages - 1,
412                    page.size
413                ));
414            }
415
416            if page.number > 0 {
417                self = self.prev_link(format!(
418                    "{}?page={}&size={}",
419                    base_url,
420                    page.number - 1,
421                    page.size
422                ));
423            }
424
425            if page.number < page.total_pages.saturating_sub(1) {
426                self = self.next_link(format!(
427                    "{}?page={}&size={}",
428                    base_url,
429                    page.number + 1,
430                    page.size
431                ));
432            }
433        }
434        self
435    }
436}
437
438/// Helper trait for adding HATEOAS links to any type
439pub trait Linkable: Sized + Serialize {
440    /// Wrap this value in a Resource with HATEOAS links
441    fn with_links(self) -> Resource<Self> {
442        Resource::new(self)
443    }
444}
445
446// Implement Linkable for all Serialize types
447impl<T: Serialize> Linkable for T {}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use serde::Serialize;
453
454    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
455    struct User {
456        id: i64,
457        name: String,
458    }
459
460    #[test]
461    fn test_link_creation() {
462        let link = Link::new("/users/1")
463            .title("Get user")
464            .media_type("application/json");
465
466        assert_eq!(link.href, "/users/1");
467        assert_eq!(link.title, Some("Get user".to_string()));
468        assert_eq!(link.media_type, Some("application/json".to_string()));
469    }
470
471    #[test]
472    fn test_templated_link() {
473        let link = Link::templated("/users/{id}");
474        assert!(link.templated.unwrap());
475    }
476
477    #[test]
478    fn test_resource_with_links() {
479        let user = User {
480            id: 1,
481            name: "John".to_string(),
482        };
483        let resource = Resource::new(user)
484            .self_link("/users/1")
485            .link("orders", "/users/1/orders");
486
487        assert!(resource.links.contains_key("self"));
488        assert!(resource.links.contains_key("orders"));
489
490        let json = serde_json::to_string_pretty(&resource).unwrap();
491        assert!(json.contains("_links"));
492        assert!(json.contains("/users/1"));
493    }
494
495    #[test]
496    fn test_resource_collection() {
497        let users = vec![
498            User {
499                id: 1,
500                name: "John".to_string(),
501            },
502            User {
503                id: 2,
504                name: "Jane".to_string(),
505            },
506        ];
507
508        let page = PageInfo::calculate(100, 20, 2);
509        let collection = ResourceCollection::new("users", users)
510            .page_info(page)
511            .with_pagination("/api/users");
512
513        assert!(collection.links.contains_key("self"));
514        assert!(collection.links.contains_key("first"));
515        assert!(collection.links.contains_key("prev"));
516        assert!(collection.links.contains_key("next"));
517    }
518
519    #[test]
520    fn test_page_info_calculation() {
521        let page = PageInfo::calculate(95, 20, 0);
522        assert_eq!(page.total_pages, 5);
523        assert_eq!(page.size, 20);
524    }
525
526    #[test]
527    fn test_linkable_trait() {
528        let user = User {
529            id: 1,
530            name: "Test".to_string(),
531        };
532        let resource = user.with_links().self_link("/users/1");
533        assert!(resource.links.contains_key("self"));
534    }
535}