1use crate::response::{IntoResponse, Response};
38use crate::status::Status;
39use rustlavel_core::Json;
40use std::collections::BTreeMap;
41
42pub trait JsonResource {
44 type Model;
45
46 fn to_json(model: &Self::Model) -> Json;
48
49 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#[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 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 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 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 pub fn path(mut self, path: &str) -> Self {
121 self.path = path.to_string();
122 self
123 }
124
125 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 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 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 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 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 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
244pub fn attributes() -> Attributes {
246 Attributes::default()
247}
248
249#[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 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 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 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}