Skip to main content

ferro_rs/http/resources/
resource_collection.rs

1use super::pagination::PaginationMeta;
2use super::resource::Resource;
3use crate::http::{HttpResponse, Request};
4use serde_json::{json, Value};
5
6/// A collection of resources with optional pagination metadata.
7///
8/// Wraps `Vec<T: Resource>` and produces the standard JSON envelope:
9/// - Without pagination: `{"data": [...]}`
10/// - With pagination: `{"data": [...], "meta": {...}, "links": {...}}`
11///
12/// # Example
13///
14/// ```rust,ignore
15/// use ferro::{Resource, ResourceCollection, PaginationMeta};
16///
17/// let resources: Vec<UserResource> = users.into_iter()
18///     .map(UserResource::from)
19///     .collect();
20///
21/// // Without pagination
22/// let collection = ResourceCollection::new(resources);
23/// Ok(collection.to_response(&req))
24///
25/// // With pagination
26/// let meta = PaginationMeta::new(page, per_page, total);
27/// let collection = ResourceCollection::paginated(resources, meta);
28/// Ok(collection.to_response(&req))
29/// ```
30pub struct ResourceCollection<T: Resource> {
31    items: Vec<T>,
32    pagination: Option<PaginationMeta>,
33    additional: Option<Value>,
34}
35
36impl<T: Resource> ResourceCollection<T> {
37    /// Create a collection without pagination.
38    pub fn new(items: Vec<T>) -> Self {
39        Self {
40            items,
41            pagination: None,
42            additional: None,
43        }
44    }
45
46    /// Create a collection with pagination metadata.
47    pub fn paginated(items: Vec<T>, meta: PaginationMeta) -> Self {
48        Self {
49            items,
50            pagination: Some(meta),
51            additional: None,
52        }
53    }
54
55    /// Add extra top-level fields merged alongside data/meta/links.
56    pub fn additional(mut self, value: Value) -> Self {
57        self.additional = Some(value);
58        self
59    }
60
61    /// Produce the JSON response.
62    ///
63    /// - Without pagination: `{"data": [...]}`
64    /// - With pagination: `{"data": [...], "meta": {...}, "links": {...}}`
65    /// - With additional: merges additional fields at top level
66    pub fn to_response(&self, req: &Request) -> HttpResponse {
67        let data: Vec<Value> = self
68            .items
69            .iter()
70            .map(|item| item.to_resource(req))
71            .collect();
72
73        let mut response = json!({ "data": data });
74
75        if let Some(meta) = &self.pagination {
76            let path = req.path();
77            let query = req.inner().uri().query();
78            let links = meta.links(path, query);
79
80            if let Some(obj) = response.as_object_mut() {
81                obj.insert("meta".to_string(), serde_json::to_value(meta).unwrap());
82                obj.insert("links".to_string(), serde_json::to_value(&links).unwrap());
83            }
84        }
85
86        if let Some(additional) = &self.additional {
87            if let (Some(obj), Some(add)) = (response.as_object_mut(), additional.as_object()) {
88                for (k, v) in add {
89                    obj.insert(k.clone(), v.clone());
90                }
91            }
92        }
93
94        HttpResponse::json(response)
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::http::resources::ResourceMap;
102    use serde_json::json;
103    use std::sync::Arc;
104    use tokio::sync::oneshot;
105
106    struct TestItem {
107        id: i32,
108        name: String,
109    }
110
111    impl Resource for TestItem {
112        fn to_resource(&self, _req: &Request) -> Value {
113            ResourceMap::new()
114                .field("id", json!(self.id))
115                .field("name", json!(self.name))
116                .build()
117        }
118    }
119
120    /// Helper to execute a closure with a real Request object.
121    /// Uses a TCP loopback to create a genuine hyper::body::Incoming.
122    async fn with_test_request_uri<F, R>(uri: &str, f: F) -> R
123    where
124        F: FnOnce(Request) -> R + Send + 'static,
125        R: Send + 'static,
126    {
127        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
128        let addr = listener.local_addr().unwrap();
129        let (result_tx, result_rx) = oneshot::channel();
130
131        let callback = Arc::new(std::sync::Mutex::new(Some(f)));
132        let tx_holder = Arc::new(std::sync::Mutex::new(Some(result_tx)));
133
134        let server = tokio::spawn(async move {
135            let (stream, _) = listener.accept().await.unwrap();
136            let io = hyper_util::rt::TokioIo::new(stream);
137
138            let _ = hyper::server::conn::http1::Builder::new()
139                .serve_connection(
140                    io,
141                    hyper::service::service_fn({
142                        let callback = callback.clone();
143                        let tx_holder = tx_holder.clone();
144                        move |req| {
145                            let cb = callback.lock().unwrap().take();
146                            let tx = tx_holder.lock().unwrap().take();
147                            let ferro_req = Request::new(req);
148                            if let (Some(cb), Some(tx)) = (cb, tx) {
149                                let result = cb(ferro_req);
150                                let _ = tx.send(result);
151                            }
152                            async {
153                                Ok::<_, std::convert::Infallible>(hyper::Response::new(
154                                    http_body_util::Full::new(bytes::Bytes::from("ok")),
155                                ))
156                            }
157                        }
158                    }),
159                )
160                .await;
161        });
162
163        let stream = tokio::net::TcpStream::connect(addr).await.unwrap();
164        let io = hyper_util::rt::TokioIo::new(stream);
165        let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await.unwrap();
166        tokio::spawn(conn);
167
168        let req = hyper::Request::builder()
169            .uri(uri)
170            .body(http_body_util::Empty::<bytes::Bytes>::new())
171            .unwrap();
172        let _ = sender.send_request(req).await;
173
174        let result = result_rx.await.expect("server should have sent result");
175        server.abort();
176        result
177    }
178
179    fn make_items(count: usize) -> Vec<TestItem> {
180        (1..=count)
181            .map(|i| TestItem {
182                id: i as i32,
183                name: format!("Item {i}"),
184            })
185            .collect()
186    }
187
188    #[tokio::test]
189    async fn test_collection_no_pagination() {
190        let items = make_items(2);
191        let result = with_test_request_uri("/items", move |req| {
192            let collection = ResourceCollection::new(items);
193            let response = collection.to_response(&req);
194            let body: Value = serde_json::from_str(response.body()).unwrap();
195            body
196        })
197        .await;
198
199        assert_eq!(
200            result,
201            json!({
202                "data": [
203                    {"id": 1, "name": "Item 1"},
204                    {"id": 2, "name": "Item 2"}
205                ]
206            })
207        );
208    }
209
210    #[tokio::test]
211    async fn test_collection_with_pagination() {
212        let items = make_items(2);
213        let result = with_test_request_uri("/items?page=1", move |req| {
214            let meta = PaginationMeta::new(1, 2, 6);
215            let collection = ResourceCollection::paginated(items, meta);
216            let response = collection.to_response(&req);
217            let body: Value = serde_json::from_str(response.body()).unwrap();
218            body
219        })
220        .await;
221
222        let obj = result.as_object().unwrap();
223        assert!(obj.contains_key("data"));
224        assert!(obj.contains_key("meta"));
225        assert!(obj.contains_key("links"));
226
227        let meta = &obj["meta"];
228        assert_eq!(meta["current_page"], 1);
229        assert_eq!(meta["per_page"], 2);
230        assert_eq!(meta["total"], 6);
231        assert_eq!(meta["last_page"], 3);
232        assert_eq!(meta["from"], 1);
233        assert_eq!(meta["to"], 2);
234
235        let links = &obj["links"];
236        assert_eq!(links["first"], "/items?page=1");
237        assert_eq!(links["last"], "/items?page=3");
238        assert!(links["prev"].is_null());
239        assert_eq!(links["next"], "/items?page=2");
240    }
241
242    #[tokio::test]
243    async fn test_collection_with_additional() {
244        let items = make_items(1);
245        let result = with_test_request_uri("/items", move |req| {
246            let collection = ResourceCollection::new(items).additional(json!({"version": "v1"}));
247            let response = collection.to_response(&req);
248            let body: Value = serde_json::from_str(response.body()).unwrap();
249            body
250        })
251        .await;
252
253        let obj = result.as_object().unwrap();
254        assert!(obj.contains_key("data"));
255        assert_eq!(obj["version"], "v1");
256    }
257
258    #[tokio::test]
259    async fn test_collection_empty() {
260        let items: Vec<TestItem> = vec![];
261        let result = with_test_request_uri("/items", move |req| {
262            let collection = ResourceCollection::new(items);
263            let response = collection.to_response(&req);
264            let body: Value = serde_json::from_str(response.body()).unwrap();
265            body
266        })
267        .await;
268
269        assert_eq!(result, json!({"data": []}));
270    }
271}