Skip to main content

rustlavel_http/
json_resource.rs

1//! API resources: one place per type that decides how it appears as JSON.
2//!
3//! Without this, every controller that returns a user decides for itself
4//! which columns to send, how to format the dates, and whether the password
5//! hash is included — and one of them will get it wrong. With it, a `User`
6//! has exactly one JSON shape, declared once:
7//!
8//! ```ignore
9//! pub struct UserResource;
10//!
11//! impl JsonResource for UserResource {
12//!     type Model = User;
13//!
14//!     fn to_json(user: &User) -> Json {
15//!         attributes()
16//!             .set("id", user.id)
17//!             .set("name", &user.name)
18//!             .set("email", &user.email)
19//!             .when(user.is_admin, "permissions", || Json::from(vec!["*"]))
20//!             .when_some("avatar_url", user.avatar_url.as_deref())
21//!             .finish()
22//!     }
23//! }
24//!
25//! // In a controller:
26//! UserResource::make(&user)                         // {"data": {…}}
27//! UserResource::make(&user).created()               // 201
28//! UserResource::collection(&users)                  // {"data": [{…}, …]}
29//! UserResource::collection(&page.hydrate()?)
30//!     .paginate(page.current_page, page.per_page, page.total)
31//!     .path("/api/users")                           // + "meta" and "links"
32//! ```
33//!
34//! The shapes are Laravel's, down to the key names in `meta` and `links`, so
35//! a front end written against a Laravel API needs no changes.
36
37use crate::response::{IntoResponse, Response};
38use crate::status::Status;
39use rustlavel_core::Json;
40use std::collections::BTreeMap;
41
42/// How a model becomes JSON. Implement it once per type that leaves the server.
43pub trait JsonResource {
44    type Model;
45
46    /// The representation of one model.
47    fn to_json(model: &Self::Model) -> Json;
48
49    /// The key the data sits under. `Some("data")` by default, as in Laravel;
50    /// `None` sends the object or array bare.
51    fn wrap() -> Option<&'static str> {
52        Some("data")
53    }
54
55    fn make(model: &Self::Model) -> ResourceResponse {
56        ResourceResponse::new(Self::to_json(model), Self::wrap())
57    }
58
59    fn collection<'a, I>(models: I) -> ResourceResponse
60    where
61        I: IntoIterator<Item = &'a Self::Model>,
62        Self::Model: 'a,
63    {
64        let items = models.into_iter().map(Self::to_json).collect();
65        ResourceResponse::new(Json::Array(items), Self::wrap())
66    }
67}
68
69/// A resource on its way out: the data, whatever travels beside it, and the
70/// status it goes with.
71#[derive(Debug, Clone)]
72pub struct ResourceResponse {
73    data: Json,
74    wrap: Option<&'static str>,
75    additional: BTreeMap<String, Json>,
76    meta: BTreeMap<String, Json>,
77    links: BTreeMap<String, Json>,
78    pagination: Option<Pagination>,
79    path: String,
80    status: Status,
81    headers: Vec<(String, String)>,
82}
83
84impl ResourceResponse {
85    pub fn new(data: Json, wrap: Option<&'static str>) -> Self {
86        ResourceResponse {
87            data,
88            wrap,
89            additional: BTreeMap::new(),
90            meta: BTreeMap::new(),
91            links: BTreeMap::new(),
92            pagination: None,
93            path: String::new(),
94            status: Status::OK,
95            headers: Vec::new(),
96        }
97    }
98
99    /// Add a top-level key beside the data.
100    pub fn additional(mut self, key: &str, value: impl Into<Json>) -> Self {
101        self.additional.insert(key.to_string(), value.into());
102        self
103    }
104
105    /// Add a key under `meta`.
106    pub fn meta(mut self, key: &str, value: impl Into<Json>) -> Self {
107        self.meta.insert(key.to_string(), value.into());
108        self
109    }
110
111    /// Add a key under `links`.
112    pub fn link(mut self, key: &str, value: impl Into<Json>) -> Self {
113        self.links.insert(key.to_string(), value.into());
114        self
115    }
116
117    /// The path the pagination links are built on. Without it they are
118    /// relative — `?page=2` — which every client resolves correctly against
119    /// the URL it just requested.
120    pub fn path(mut self, path: &str) -> Self {
121        self.path = path.to_string();
122        self
123    }
124
125    /// Describe page-number pagination, with Laravel's `meta` and `links`.
126    ///
127    /// Three numbers rather than a `Page`, so this crate does not have to know
128    /// about the database — and so it works for a page that came from
129    /// anywhere else.
130    pub fn paginate(mut self, current_page: i64, per_page: i64, total: i64) -> Self {
131        let per_page = per_page.max(1);
132        let last_page = (total + per_page - 1) / per_page;
133        let last_page = last_page.max(1);
134        let (from, to) = if total == 0 {
135            (Json::Null, Json::Null)
136        } else {
137            let from = (current_page - 1) * per_page + 1;
138            (Json::from(from), Json::from((from + per_page - 1).min(total)))
139        };
140
141        self.meta.insert("current_page".into(), Json::from(current_page));
142        self.meta.insert("from".into(), from);
143        self.meta.insert("last_page".into(), Json::from(last_page));
144        self.meta.insert("per_page".into(), Json::from(per_page));
145        self.meta.insert("to".into(), to);
146        self.meta.insert("total".into(), Json::from(total));
147        self.pagination = Some(Pagination::Pages { current: current_page, last: last_page });
148        self
149    }
150
151    /// Describe cursor pagination: a `next_cursor` that is `null` at the end.
152    pub fn cursor(mut self, next_cursor: Option<String>, per_page: i64) -> Self {
153        self.meta.insert("per_page".into(), Json::from(per_page));
154        self.meta.insert("next_cursor".into(), next_cursor.clone().map_or(Json::Null, Json::from));
155        self.pagination = Some(Pagination::Cursor { next: next_cursor });
156        self
157    }
158
159    /// The `links` object: whatever pagination implies, then whatever was
160    /// added by hand, which wins on a clash.
161    fn links(&self) -> BTreeMap<String, Json> {
162        let mut links = BTreeMap::new();
163        let url = |query: String| Json::from(format!("{}?{query}", self.path));
164        match &self.pagination {
165            Some(Pagination::Pages { current, last }) => {
166                links.insert("first".into(), url("page=1".into()));
167                links.insert("last".into(), url(format!("page={last}")));
168                links.insert("prev".into(), if *current > 1 { url(format!("page={}", current - 1)) } else { Json::Null });
169                links.insert("next".into(), if current < last { url(format!("page={}", current + 1)) } else { Json::Null });
170            }
171            Some(Pagination::Cursor { next }) => {
172                links.insert("prev".into(), Json::Null);
173                links.insert(
174                    "next".into(),
175                    next.as_ref().map_or(Json::Null, |c| url(format!("cursor={}", crate::url::encode(c)))),
176                );
177            }
178            None => {}
179        }
180        links.extend(self.links.iter().map(|(k, v)| (k.clone(), v.clone())));
181        links
182    }
183
184    pub fn with_status(mut self, status: impl Into<Status>) -> Self {
185        self.status = status.into();
186        self
187    }
188
189    /// `201 Created`, for the response to a successful store.
190    pub fn created(self) -> Self {
191        self.with_status(Status::CREATED)
192    }
193
194    pub fn with_header(mut self, name: &str, value: impl Into<String>) -> Self {
195        self.headers.push((name.to_string(), value.into()));
196        self
197    }
198
199    /// The body as it will be sent.
200    pub fn to_json(&self) -> Json {
201        let links = self.links();
202        let has_siblings = !self.additional.is_empty() || !self.meta.is_empty() || !links.is_empty();
203
204        // Unwrapped data can only stand alone. The moment it has company —
205        // pagination, an extra key — it goes under `data` regardless, because
206        // there is nowhere else for the company to go. This is Laravel's rule.
207        let key = match self.wrap {
208            Some(key) => key,
209            None if has_siblings => "data",
210            None => return self.data.clone(),
211        };
212
213        let mut object = BTreeMap::new();
214        object.insert(key.to_string(), self.data.clone());
215        if !links.is_empty() {
216            object.insert("links".into(), Json::Object(links));
217        }
218        if !self.meta.is_empty() {
219            object.insert("meta".into(), Json::Object(self.meta.clone()));
220        }
221        for (k, v) in &self.additional {
222            object.insert(k.clone(), v.clone());
223        }
224        Json::Object(object)
225    }
226}
227
228#[derive(Debug, Clone)]
229enum Pagination {
230    Pages { current: i64, last: i64 },
231    Cursor { next: Option<String> },
232}
233
234impl IntoResponse for ResourceResponse {
235    fn into_response(self) -> Response {
236        let mut response = Response::new(self.status).with_json(self.to_json());
237        for (name, value) in &self.headers {
238            response.headers.set(name, value.clone());
239        }
240        response
241    }
242}
243
244/// Start building the attributes of a resource.
245pub fn attributes() -> Attributes {
246    Attributes::default()
247}
248
249/// An object under construction, with the conditionals a resource needs.
250///
251/// The point of `when` is the key that is *absent*, not null. A client told
252/// `"permissions": null` has to decide what null means; a client not told
253/// about permissions at all knows it is not allowed to see them.
254#[derive(Debug, Default, Clone)]
255pub struct Attributes {
256    fields: BTreeMap<String, Json>,
257}
258
259impl Attributes {
260    pub fn set(mut self, key: &str, value: impl Into<Json>) -> Self {
261        self.fields.insert(key.to_string(), value.into());
262        self
263    }
264
265    /// Include the key only when the condition holds. The value is computed
266    /// lazily, so it may be expensive or may only be valid when the condition
267    /// is true.
268    pub fn when(mut self, condition: bool, key: &str, value: impl FnOnce() -> Json) -> Self {
269        if condition {
270            self.fields.insert(key.to_string(), value());
271        }
272        self
273    }
274
275    /// Include the key only when there is a value — `Some`, not `None`.
276    pub fn when_some<T: Into<Json>>(mut self, key: &str, value: Option<T>) -> Self {
277        if let Some(value) = value {
278            self.fields.insert(key.to_string(), value.into());
279        }
280        self
281    }
282
283    /// Merge another object's keys in — for a nested resource, or a shared
284    /// set of timestamps.
285    pub fn merge(mut self, other: Json) -> Self {
286        if let Json::Object(fields) = other {
287            self.fields.extend(fields);
288        }
289        self
290    }
291
292    pub fn finish(self) -> Json {
293        Json::Object(self.fields)
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    struct User {
302        id: i64,
303        name: String,
304        email: String,
305        password_hash: String,
306        is_admin: bool,
307        avatar: Option<String>,
308    }
309
310    fn alice() -> User {
311        User {
312            id: 1,
313            name: "Alice".into(),
314            email: "alice@example.com".into(),
315            password_hash: "$argon2id$…".into(),
316            is_admin: true,
317            avatar: None,
318        }
319    }
320
321    fn bob() -> User {
322        User { id: 2, name: "Bob".into(), is_admin: false, avatar: Some("/b.png".into()), ..alice() }
323    }
324
325    struct UserResource;
326
327    impl JsonResource for UserResource {
328        type Model = User;
329
330        fn to_json(user: &User) -> Json {
331            attributes()
332                .set("id", user.id)
333                .set("name", user.name.as_str())
334                .set("email", user.email.as_str())
335                .when(user.is_admin, "permissions", || Json::from(vec!["*"]))
336                .when_some("avatar", user.avatar.as_deref())
337                .finish()
338        }
339    }
340
341    struct BareUser;
342
343    impl JsonResource for BareUser {
344        type Model = User;
345        fn to_json(user: &User) -> Json {
346            Json::object([("id", Json::from(user.id))])
347        }
348        fn wrap() -> Option<&'static str> {
349            None
350        }
351    }
352
353    #[test]
354    fn a_single_resource_is_wrapped_in_data() {
355        let json = UserResource::make(&alice()).to_json();
356        assert_eq!(json.get("data.id").and_then(Json::as_i64), Some(1));
357        assert_eq!(json.get("data.name").and_then(Json::as_str), Some("Alice"));
358        assert!(json.get("data.password_hash").is_none(), "only what the resource names leaves");
359    }
360
361    #[test]
362    fn conditional_attributes_are_absent_not_null() {
363        let admin = UserResource::to_json(&alice());
364        let plain = UserResource::to_json(&bob());
365
366        assert!(admin.get("permissions").is_some());
367        assert!(plain.get("permissions").is_none(), "absent, so the client knows it may not ask");
368        assert!(admin.get("avatar").is_none());
369        assert_eq!(plain.get("avatar").and_then(Json::as_str), Some("/b.png"));
370        let _ = alice().password_hash;
371    }
372
373    #[test]
374    fn a_collection_is_an_array_under_data() {
375        let users = vec![alice(), bob()];
376        let json = UserResource::collection(&users).to_json();
377        let data = json.get("data").and_then(Json::as_array).expect("an array");
378        assert_eq!(data.len(), 2);
379        assert_eq!(data[1].get("name").and_then(Json::as_str), Some("Bob"));
380    }
381
382    #[test]
383    fn wrapping_can_be_turned_off() {
384        let json = BareUser::make(&alice()).to_json();
385        assert_eq!(json.get("id").and_then(Json::as_i64), Some(1));
386        assert!(json.get("data").is_none());
387    }
388
389    #[test]
390    fn unwrapped_data_is_wrapped_anyway_once_it_has_company() {
391        let json = BareUser::make(&alice()).additional("version", "2").to_json();
392        assert_eq!(json.get("data.id").and_then(Json::as_i64), Some(1));
393        assert_eq!(json.get("version").and_then(Json::as_str), Some("2"));
394    }
395
396    #[test]
397    fn pagination_produces_laravels_meta_and_links() {
398        let users = vec![alice(), bob()];
399        let json = UserResource::collection(&users).paginate(2, 2, 5).path("/api/users").to_json();
400
401        assert_eq!(json.get("meta.current_page").and_then(Json::as_i64), Some(2));
402        assert_eq!(json.get("meta.per_page").and_then(Json::as_i64), Some(2));
403        assert_eq!(json.get("meta.total").and_then(Json::as_i64), Some(5));
404        assert_eq!(json.get("meta.last_page").and_then(Json::as_i64), Some(3));
405        assert_eq!(json.get("meta.from").and_then(Json::as_i64), Some(3));
406        assert_eq!(json.get("meta.to").and_then(Json::as_i64), Some(4));
407
408        assert_eq!(json.get("links.first").and_then(Json::as_str), Some("/api/users?page=1"));
409        assert_eq!(json.get("links.last").and_then(Json::as_str), Some("/api/users?page=3"));
410        assert_eq!(json.get("links.prev").and_then(Json::as_str), Some("/api/users?page=1"));
411        assert_eq!(json.get("links.next").and_then(Json::as_str), Some("/api/users?page=3"));
412    }
413
414    #[test]
415    fn the_first_and_last_pages_have_null_neighbours() {
416        let users = vec![alice()];
417        let first = UserResource::collection(&users).paginate(1, 10, 25).to_json();
418        assert!(first.get("links.prev").unwrap().is_null());
419        assert_eq!(first.get("links.next").and_then(Json::as_str), Some("?page=2"), "relative without a path");
420
421        let last = UserResource::collection(&users).paginate(3, 10, 25).to_json();
422        assert!(last.get("links.next").unwrap().is_null());
423        assert_eq!(last.get("meta.to").and_then(Json::as_i64), Some(25), "clamped to the total");
424    }
425
426    #[test]
427    fn an_empty_page_has_null_from_and_to_and_one_last_page() {
428        let json = UserResource::collection(&Vec::<User>::new()).paginate(1, 10, 0).to_json();
429        assert!(json.get("meta.from").unwrap().is_null());
430        assert!(json.get("meta.to").unwrap().is_null());
431        assert_eq!(json.get("meta.last_page").and_then(Json::as_i64), Some(1));
432    }
433
434    #[test]
435    fn cursor_pagination_encodes_the_cursor_into_the_link() {
436        let users = vec![alice()];
437        let json = UserResource::collection(&users)
438            .cursor(Some("id>42&x".into()), 10)
439            .path("/api/users")
440            .to_json();
441        assert_eq!(json.get("meta.next_cursor").and_then(Json::as_str), Some("id>42&x"));
442        assert_eq!(json.get("links.next").and_then(Json::as_str), Some("/api/users?cursor=id%3E42%26x"));
443
444        let end = UserResource::collection(&users).cursor(None, 10).to_json();
445        assert!(end.get("meta.next_cursor").unwrap().is_null());
446        assert!(end.get("links.next").unwrap().is_null());
447    }
448
449    #[test]
450    fn it_becomes_a_json_response_with_the_chosen_status_and_headers() {
451        let response = UserResource::make(&alice())
452            .created()
453            .with_header("location", "/api/users/1")
454            .into_response();
455        assert_eq!(response.status, Status::CREATED);
456        assert!(response.headers.content_type().unwrap().starts_with("application/json"));
457        assert_eq!(response.headers.get("location"), Some("/api/users/1"));
458        assert!(response.body_string().contains("\"Alice\""));
459    }
460
461    #[test]
462    fn attributes_merge_nested_objects() {
463        let json = attributes()
464            .set("id", 1)
465            .merge(Json::object([("created_at", Json::from("2026-01-01"))]))
466            .merge(Json::from("not an object, ignored"))
467            .finish();
468        assert_eq!(json.get("created_at").and_then(Json::as_str), Some("2026-01-01"));
469        assert_eq!(json.as_object().unwrap().len(), 2);
470    }
471}