Skip to main content

ferro_rs/http/resources/
resource.rs

1use crate::http::{HttpResponse, Request};
2
3#[allow(unused_imports)]
4use super::ResourceMap;
5
6/// Trait for transforming models into JSON API responses.
7///
8/// Implement this trait on resource structs to define how models are
9/// serialized for API consumers. The `Request` parameter enables
10/// context-dependent field selection (e.g., based on auth or roles).
11///
12/// # Example
13///
14/// ```rust,ignore
15/// use ferro_rs::{Resource, ResourceMap, Request};
16/// use serde_json::json;
17///
18/// struct UserResource {
19///     id: i32,
20///     name: String,
21///     email: String,
22/// }
23///
24/// impl Resource for UserResource {
25///     fn to_resource(&self, _req: &Request) -> serde_json::Value {
26///         ResourceMap::new()
27///             .field("id", json!(self.id))
28///             .field("name", json!(self.name))
29///             .field("email", json!(self.email))
30///             .build()
31///     }
32/// }
33/// ```
34pub trait Resource {
35    /// Transform this into a JSON value for API responses.
36    /// Request is available for context-dependent field selection (auth, roles, etc).
37    fn to_resource(&self, req: &Request) -> serde_json::Value;
38
39    /// Return a JSON HTTP response with the resource data.
40    fn to_response(&self, req: &Request) -> HttpResponse {
41        HttpResponse::json(self.to_resource(req))
42    }
43
44    /// Return a JSON HTTP response wrapped in `{"data": ...}` envelope.
45    fn to_wrapped_response(&self, req: &Request) -> HttpResponse {
46        HttpResponse::json(serde_json::json!({"data": self.to_resource(req)}))
47    }
48
49    /// Map a slice of resources to their JSON representations.
50    ///
51    /// Convenience for `items.iter().map(|item| item.to_resource(req)).collect()`.
52    ///
53    /// # Example
54    ///
55    /// ```rust,ignore
56    /// let users: Vec<UserResource> = /* ... */;
57    /// let json_array = UserResource::collection(&users, &req);
58    /// // Returns: Vec<serde_json::Value>
59    /// ```
60    fn collection(items: &[Self], req: &Request) -> Vec<serde_json::Value>
61    where
62        Self: Sized,
63    {
64        items.iter().map(|item| item.to_resource(req)).collect()
65    }
66
67    /// Return a wrapped response with additional top-level fields merged.
68    ///
69    /// # Example
70    ///
71    /// ```rust,ignore
72    /// let response = resource.to_response_with(&req, json!({"meta": {"version": "v1"}}));
73    /// // Output: {"data": {...}, "meta": {"version": "v1"}}
74    /// ```
75    fn to_response_with(&self, req: &Request, additional: serde_json::Value) -> HttpResponse {
76        let mut response = serde_json::json!({"data": self.to_resource(req)});
77        if let (Some(obj), Some(add)) = (response.as_object_mut(), additional.as_object()) {
78            for (k, v) in add {
79                obj.insert(k.clone(), v.clone());
80            }
81        }
82        HttpResponse::json(response)
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use serde_json::json;
90    use std::sync::Arc;
91    use tokio::sync::oneshot;
92
93    struct TestUser {
94        id: i32,
95        name: String,
96        email: String,
97    }
98
99    impl Resource for TestUser {
100        fn to_resource(&self, _req: &Request) -> serde_json::Value {
101            ResourceMap::new()
102                .field("id", json!(self.id))
103                .field("name", json!(self.name))
104                .field("email", json!(self.email))
105                .build()
106        }
107    }
108
109    /// Helper to execute a closure with a real Request object.
110    /// Uses a TCP loopback to create a genuine hyper::body::Incoming.
111    async fn with_test_request<F, R>(f: F) -> R
112    where
113        F: FnOnce(Request) -> R + Send + 'static,
114        R: Send + 'static,
115    {
116        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
117        let addr = listener.local_addr().unwrap();
118        let (result_tx, result_rx) = oneshot::channel();
119
120        let callback = Arc::new(std::sync::Mutex::new(Some(f)));
121        let tx_holder = Arc::new(std::sync::Mutex::new(Some(result_tx)));
122
123        let server = tokio::spawn(async move {
124            let (stream, _) = listener.accept().await.unwrap();
125            let io = hyper_util::rt::TokioIo::new(stream);
126
127            let _ = hyper::server::conn::http1::Builder::new()
128                .serve_connection(
129                    io,
130                    hyper::service::service_fn({
131                        let callback = callback.clone();
132                        let tx_holder = tx_holder.clone();
133                        move |req| {
134                            let cb = callback.lock().unwrap().take();
135                            let tx = tx_holder.lock().unwrap().take();
136                            let ferro_req = Request::new(req);
137                            if let (Some(cb), Some(tx)) = (cb, tx) {
138                                let result = cb(ferro_req);
139                                let _ = tx.send(result);
140                            }
141                            async {
142                                Ok::<_, std::convert::Infallible>(hyper::Response::new(
143                                    http_body_util::Full::new(bytes::Bytes::from("ok")),
144                                ))
145                            }
146                        }
147                    }),
148                )
149                .await;
150        });
151
152        // Send a GET request to trigger the callback
153        let stream = tokio::net::TcpStream::connect(addr).await.unwrap();
154        let io = hyper_util::rt::TokioIo::new(stream);
155        let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await.unwrap();
156        tokio::spawn(conn);
157
158        let req = hyper::Request::builder()
159            .uri("/")
160            .body(http_body_util::Empty::<bytes::Bytes>::new())
161            .unwrap();
162        let _ = sender.send_request(req).await;
163
164        let result = result_rx.await.expect("server should have sent result");
165        server.abort();
166        result
167    }
168
169    #[tokio::test]
170    async fn test_resource_to_resource() {
171        let user = TestUser {
172            id: 42,
173            name: "Alice".to_string(),
174            email: "alice@example.com".to_string(),
175        };
176
177        let result = with_test_request(move |req| user.to_resource(&req)).await;
178        assert_eq!(
179            result,
180            json!({"id": 42, "name": "Alice", "email": "alice@example.com"})
181        );
182    }
183
184    #[tokio::test]
185    async fn test_resource_to_response() {
186        let user = TestUser {
187            id: 1,
188            name: "Bob".to_string(),
189            email: "bob@example.com".to_string(),
190        };
191
192        let status = with_test_request(move |req| user.to_response(&req).status_code()).await;
193        assert_eq!(status, 200);
194    }
195
196    #[tokio::test]
197    async fn test_resource_to_wrapped_response() {
198        let user = TestUser {
199            id: 1,
200            name: "Bob".to_string(),
201            email: "bob@example.com".to_string(),
202        };
203
204        let status =
205            with_test_request(move |req| user.to_wrapped_response(&req).status_code()).await;
206        assert_eq!(status, 200);
207    }
208
209    #[tokio::test]
210    async fn test_resource_collection() {
211        let users = vec![
212            TestUser {
213                id: 1,
214                name: "Alice".to_string(),
215                email: "alice@example.com".to_string(),
216            },
217            TestUser {
218                id: 2,
219                name: "Bob".to_string(),
220                email: "bob@example.com".to_string(),
221            },
222            TestUser {
223                id: 3,
224                name: "Charlie".to_string(),
225                email: "charlie@example.com".to_string(),
226            },
227        ];
228
229        let result = with_test_request(move |req| TestUser::collection(&users, &req)).await;
230
231        assert_eq!(result.len(), 3);
232        assert_eq!(
233            result[0],
234            json!({"id": 1, "name": "Alice", "email": "alice@example.com"})
235        );
236        assert_eq!(
237            result[1],
238            json!({"id": 2, "name": "Bob", "email": "bob@example.com"})
239        );
240        assert_eq!(
241            result[2],
242            json!({"id": 3, "name": "Charlie", "email": "charlie@example.com"})
243        );
244    }
245}