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// ─── Paginated Response ─────────────────────────────────────────────────────
451
452/// Paginated response wrapper with metadata and navigation links.
453///
454/// Automatically generates:
455/// - JSON body with `items`, `meta` (page, per_page, total, total_pages), and `_links`
456/// - RFC 8288 `Link` response headers for first/prev/next/last
457///
458/// # Example
459///
460/// ```rust,ignore
461/// use rustapi_core::{Paginate, Json};
462/// use rustapi_core::hateoas::Paginated;
463///
464/// async fn list_users(paginate: Paginate) -> Paginated<User> {
465///     let users = db.query_users(paginate.offset(), paginate.limit()).await;
466///     let total = db.count_users().await;
467///     paginate.paginate(users, total)
468/// }
469/// ```
470#[derive(Debug, Clone)]
471pub struct Paginated<T> {
472    /// The items for this page
473    pub items: Vec<T>,
474    /// Current page number (1-indexed)
475    pub page: u64,
476    /// Items per page
477    pub per_page: u64,
478    /// Total number of items across all pages
479    pub total: u64,
480}
481
482impl<T> Paginated<T> {
483    /// Create a new paginated response
484    pub fn new(items: Vec<T>, page: u64, per_page: u64, total: u64) -> Self {
485        Self {
486            items,
487            page,
488            per_page,
489            total,
490        }
491    }
492
493    /// Calculate total number of pages
494    pub fn total_pages(&self) -> u64 {
495        if self.per_page == 0 {
496            return 0;
497        }
498        self.total.div_ceil(self.per_page)
499    }
500
501    /// Check if there is a next page
502    pub fn has_next(&self) -> bool {
503        self.page < self.total_pages()
504    }
505
506    /// Check if there is a previous page
507    pub fn has_prev(&self) -> bool {
508        self.page > 1
509    }
510
511    /// Map items to a different type
512    pub fn map<U, F: FnMut(T) -> U>(self, f: F) -> Paginated<U> {
513        Paginated {
514            items: self.items.into_iter().map(f).collect(),
515            page: self.page,
516            per_page: self.per_page,
517            total: self.total,
518        }
519    }
520}
521
522/// JSON representation of paginated response
523#[derive(Serialize)]
524struct PaginatedBody<T: Serialize> {
525    items: Vec<T>,
526    meta: PaginationMeta,
527    #[serde(rename = "_links")]
528    links: PaginationLinks,
529}
530
531#[derive(Serialize)]
532struct PaginationMeta {
533    page: u64,
534    per_page: u64,
535    total: u64,
536    total_pages: u64,
537}
538
539#[derive(Serialize)]
540struct PaginationLinks {
541    #[serde(rename = "self")]
542    self_link: String,
543    first: String,
544    last: String,
545    #[serde(skip_serializing_if = "Option::is_none")]
546    next: Option<String>,
547    #[serde(skip_serializing_if = "Option::is_none")]
548    prev: Option<String>,
549}
550
551impl<T: Serialize> Paginated<T> {
552    /// Generate RFC 8288 Link header value
553    fn link_header(&self, base_path: &str) -> String {
554        let total_pages = self.total_pages();
555        let mut links = Vec::new();
556
557        // first
558        links.push(format!(
559            "<{}?page=1&per_page={}>; rel=\"first\"",
560            base_path, self.per_page
561        ));
562
563        // last
564        if total_pages > 0 {
565            links.push(format!(
566                "<{}?page={}&per_page={}>; rel=\"last\"",
567                base_path, total_pages, self.per_page
568            ));
569        }
570
571        // prev
572        if self.has_prev() {
573            links.push(format!(
574                "<{}?page={}&per_page={}>; rel=\"prev\"",
575                base_path,
576                self.page - 1,
577                self.per_page
578            ));
579        }
580
581        // next
582        if self.has_next() {
583            links.push(format!(
584                "<{}?page={}&per_page={}>; rel=\"next\"",
585                base_path,
586                self.page + 1,
587                self.per_page
588            ));
589        }
590
591        links.join(", ")
592    }
593
594    /// Build the JSON body with links using a base path
595    fn to_body_with_path(&self, base_path: &str) -> PaginatedBody<&T> {
596        let total_pages = self.total_pages();
597
598        let links = PaginationLinks {
599            self_link: format!(
600                "{}?page={}&per_page={}",
601                base_path, self.page, self.per_page
602            ),
603            first: format!("{}?page=1&per_page={}", base_path, self.per_page),
604            last: format!(
605                "{}?page={}&per_page={}",
606                base_path,
607                total_pages.max(1),
608                self.per_page
609            ),
610            next: if self.has_next() {
611                Some(format!(
612                    "{}?page={}&per_page={}",
613                    base_path,
614                    self.page + 1,
615                    self.per_page
616                ))
617            } else {
618                None
619            },
620            prev: if self.has_prev() {
621                Some(format!(
622                    "{}?page={}&per_page={}",
623                    base_path,
624                    self.page - 1,
625                    self.per_page
626                ))
627            } else {
628                None
629            },
630        };
631
632        PaginatedBody {
633            items: self.items.iter().collect(),
634            meta: PaginationMeta {
635                page: self.page,
636                per_page: self.per_page,
637                total: self.total,
638                total_pages,
639            },
640            links,
641        }
642    }
643}
644
645impl<T: Serialize + Send> crate::response::IntoResponse for Paginated<T> {
646    fn into_response(self) -> crate::response::Response {
647        // Use a generic base path since we don't have access to the request URI
648        // in IntoResponse. Users can override via ResponseModifier or interceptors.
649        let base_path = "";
650        let link_header = self.link_header(base_path);
651        let body = self.to_body_with_path(base_path);
652
653        let total_count = self.total.to_string();
654
655        match crate::json::to_vec_with_capacity(&body, 512) {
656            Ok(json_bytes) => {
657                let mut response = http::Response::builder()
658                    .status(http::StatusCode::OK)
659                    .header(http::header::CONTENT_TYPE, "application/json")
660                    .header("X-Total-Count", &total_count)
661                    .header("X-Total-Pages", self.total_pages().to_string())
662                    .body(crate::response::Body::from(json_bytes))
663                    .unwrap();
664
665                if !link_header.is_empty() {
666                    response.headers_mut().insert(
667                        http::header::LINK,
668                        http::HeaderValue::from_str(&link_header)
669                            .unwrap_or_else(|_| http::HeaderValue::from_static("")),
670                    );
671                }
672
673                response
674            }
675            Err(err) => crate::error::ApiError::internal(format!(
676                "Failed to serialize paginated response: {}",
677                err
678            ))
679            .into_response(),
680        }
681    }
682}
683
684/// Cursor-based paginated response
685///
686/// # Example
687///
688/// ```rust,ignore
689/// use rustapi_core::{CursorPaginate};
690/// use rustapi_core::hateoas::CursorPaginated;
691///
692/// async fn list_events(cursor: CursorPaginate) -> CursorPaginated<Event> {
693///     let limit = cursor.limit();
694///     let events = db.query_events_after(cursor.after(), limit + 1).await;
695///     let has_more = events.len() > limit as usize;
696///     let items: Vec<Event> = events.into_iter().take(limit as usize).collect();
697///     let next_cursor = items.last().map(|e| e.id.to_string());
698///     
699///     CursorPaginated::new(items, next_cursor, has_more)
700/// }
701/// ```
702#[derive(Debug, Clone)]
703pub struct CursorPaginated<T> {
704    /// The items for this page
705    pub items: Vec<T>,
706    /// Cursor pointing to the next page (None = no more pages)
707    pub next_cursor: Option<String>,
708    /// Whether there are more items
709    pub has_more: bool,
710}
711
712impl<T> CursorPaginated<T> {
713    /// Create a new cursor-based paginated response
714    pub fn new(items: Vec<T>, next_cursor: Option<String>, has_more: bool) -> Self {
715        Self {
716            items,
717            next_cursor,
718            has_more,
719        }
720    }
721
722    /// Map items to a different type
723    pub fn map<U, F: FnMut(T) -> U>(self, f: F) -> CursorPaginated<U> {
724        CursorPaginated {
725            items: self.items.into_iter().map(f).collect(),
726            next_cursor: self.next_cursor,
727            has_more: self.has_more,
728        }
729    }
730}
731
732#[derive(Serialize)]
733struct CursorPaginatedBody<T: Serialize> {
734    items: Vec<T>,
735    meta: CursorMeta,
736}
737
738#[derive(Serialize)]
739struct CursorMeta {
740    #[serde(skip_serializing_if = "Option::is_none")]
741    next_cursor: Option<String>,
742    has_more: bool,
743}
744
745impl<T: Serialize + Send> crate::response::IntoResponse for CursorPaginated<T> {
746    fn into_response(self) -> crate::response::Response {
747        let body = CursorPaginatedBody {
748            items: self.items,
749            meta: CursorMeta {
750                next_cursor: self.next_cursor,
751                has_more: self.has_more,
752            },
753        };
754
755        match crate::json::to_vec_with_capacity(&body, 512) {
756            Ok(json_bytes) => http::Response::builder()
757                .status(http::StatusCode::OK)
758                .header(http::header::CONTENT_TYPE, "application/json")
759                .body(crate::response::Body::from(json_bytes))
760                .unwrap(),
761            Err(err) => crate::error::ApiError::internal(format!(
762                "Failed to serialize cursor-paginated response: {}",
763                err
764            ))
765            .into_response(),
766        }
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773    use rustapi_openapi::schema::{JsonSchema2020, RustApiSchema, SchemaCtx, SchemaRef};
774    use serde::Serialize;
775
776    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
777    struct User {
778        id: i64,
779        name: String,
780    }
781
782    impl RustApiSchema for User {
783        fn schema(_: &mut SchemaCtx) -> SchemaRef {
784            let mut s = JsonSchema2020::object();
785            let mut props = std::collections::BTreeMap::new();
786            props.insert("id".to_string(), JsonSchema2020::integer());
787            props.insert("name".to_string(), JsonSchema2020::string());
788            s.properties = Some(props);
789            SchemaRef::Schema(Box::new(s))
790        }
791        fn name() -> std::borrow::Cow<'static, str> {
792            std::borrow::Cow::Borrowed("User")
793        }
794    }
795
796    #[test]
797    fn test_link_creation() {
798        let link = Link::new("/users/1")
799            .title("Get user")
800            .media_type("application/json");
801
802        assert_eq!(link.href, "/users/1");
803        assert_eq!(link.title, Some("Get user".to_string()));
804        assert_eq!(link.media_type, Some("application/json".to_string()));
805    }
806
807    #[test]
808    fn test_templated_link() {
809        let link = Link::templated("/users/{id}");
810        assert!(link.templated.unwrap());
811    }
812
813    #[test]
814    fn test_resource_with_links() {
815        let user = User {
816            id: 1,
817            name: "John".to_string(),
818        };
819        let resource = Resource::new(user)
820            .self_link("/users/1")
821            .link("orders", "/users/1/orders");
822
823        assert!(resource.links.contains_key("self"));
824        assert!(resource.links.contains_key("orders"));
825
826        let json = serde_json::to_string_pretty(&resource).unwrap();
827        assert!(json.contains("_links"));
828        assert!(json.contains("/users/1"));
829    }
830
831    #[test]
832    fn test_resource_collection() {
833        let users = vec![
834            User {
835                id: 1,
836                name: "John".to_string(),
837            },
838            User {
839                id: 2,
840                name: "Jane".to_string(),
841            },
842        ];
843
844        let page = PageInfo::calculate(100, 20, 2);
845        let collection = ResourceCollection::new("users", users)
846            .page_info(page)
847            .with_pagination("/api/users");
848
849        assert!(collection.links.contains_key("self"));
850        assert!(collection.links.contains_key("first"));
851        assert!(collection.links.contains_key("prev"));
852        assert!(collection.links.contains_key("next"));
853    }
854
855    #[test]
856    fn test_page_info_calculation() {
857        let page = PageInfo::calculate(95, 20, 0);
858        assert_eq!(page.total_pages, 5);
859        assert_eq!(page.size, 20);
860    }
861
862    #[test]
863    fn test_linkable_trait() {
864        let user = User {
865            id: 1,
866            name: "Test".to_string(),
867        };
868        let resource = user.with_links().self_link("/users/1");
869        assert!(resource.links.contains_key("self"));
870    }
871}