Skip to main content

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