1use rustapi_openapi::Schema;
33use serde::{Deserialize, Serialize};
34use std::collections::HashMap;
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Schema)]
49pub struct Link {
50 pub href: String,
52
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub templated: Option<bool>,
56
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub title: Option<String>,
60
61 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
63 pub media_type: Option<String>,
64
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub deprecation: Option<String>,
68
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub name: Option<String>,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub profile: Option<String>,
76
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub hreflang: Option<String>,
80}
81
82impl Link {
83 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 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 pub fn set_templated(mut self, templated: bool) -> Self {
115 self.templated = Some(templated);
116 self
117 }
118
119 pub fn title(mut self, title: impl Into<String>) -> Self {
121 self.title = Some(title.into());
122 self
123 }
124
125 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 pub fn deprecation(mut self, deprecation_url: impl Into<String>) -> Self {
133 self.deprecation = Some(deprecation_url.into());
134 self
135 }
136
137 pub fn name(mut self, name: impl Into<String>) -> Self {
139 self.name = Some(name.into());
140 self
141 }
142
143 pub fn profile(mut self, profile: impl Into<String>) -> Self {
145 self.profile = Some(profile.into());
146 self
147 }
148
149 pub fn hreflang(mut self, hreflang: impl Into<String>) -> Self {
151 self.hreflang = Some(hreflang.into());
152 self
153 }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize, Schema)]
177pub struct Resource<T: rustapi_openapi::schema::RustApiSchema> {
178 #[serde(flatten)]
180 pub data: T,
181
182 #[serde(rename = "_links")]
184 pub links: HashMap<String, LinkOrArray>,
185
186 #[serde(rename = "_embedded", skip_serializing_if = "Option::is_none")]
188 pub embedded: Option<HashMap<String, serde_json::Value>>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Schema)]
193#[serde(untagged)]
194pub enum LinkOrArray {
195 Single(Link),
197 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 pub fn new(data: T) -> Self {
216 Self {
217 data,
218 links: HashMap::new(),
219 embedded: None,
220 }
221 }
222
223 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 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 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 pub fn self_link(self, href: impl Into<String>) -> Self {
244 self.link("self", href)
245 }
246
247 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 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#[derive(Debug, Clone, Serialize, Deserialize, Schema)]
293pub struct ResourceCollection<T: rustapi_openapi::schema::RustApiSchema> {
294 #[serde(rename = "_embedded")]
296 pub embedded: HashMap<String, Vec<T>>,
297
298 #[serde(rename = "_links")]
300 pub links: HashMap<String, LinkOrArray>,
301
302 #[serde(skip_serializing_if = "Option::is_none")]
304 pub page: Option<PageInfo>,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize, Schema)]
309pub struct PageInfo {
310 pub size: usize,
312 #[serde(rename = "totalElements")]
314 pub total_elements: usize,
315 #[serde(rename = "totalPages")]
317 pub total_pages: usize,
318 pub number: usize,
320}
321
322impl PageInfo {
323 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 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 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 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 pub fn self_link(self, href: impl Into<String>) -> Self {
367 self.link("self", href)
368 }
369
370 pub fn first_link(self, href: impl Into<String>) -> Self {
372 self.link("first", href)
373 }
374
375 pub fn last_link(self, href: impl Into<String>) -> Self {
377 self.link("last", href)
378 }
379
380 pub fn next_link(self, href: impl Into<String>) -> Self {
382 self.link("next", href)
383 }
384
385 pub fn prev_link(self, href: impl Into<String>) -> Self {
387 self.link("prev", href)
388 }
389
390 pub fn page_info(mut self, page: PageInfo) -> Self {
392 self.page = Some(page);
393 self
394 }
395
396 pub fn with_pagination(mut self, base_url: &str) -> Self {
398 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
439pub trait Linkable: Sized + Serialize + rustapi_openapi::schema::RustApiSchema {
441 fn with_links(self) -> Resource<Self> {
443 Resource::new(self)
444 }
445}
446
447impl<T: Serialize + rustapi_openapi::schema::RustApiSchema> Linkable for T {}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453 use rustapi_openapi::schema::{JsonSchema2020, RustApiSchema, SchemaCtx, SchemaRef};
454 use serde::Serialize;
455
456 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
457 struct User {
458 id: i64,
459 name: String,
460 }
461
462 impl RustApiSchema for User {
463 fn schema(_: &mut SchemaCtx) -> SchemaRef {
464 let mut s = JsonSchema2020::object();
465 let mut props = std::collections::BTreeMap::new();
466 props.insert("id".to_string(), JsonSchema2020::integer());
467 props.insert("name".to_string(), JsonSchema2020::string());
468 s.properties = Some(props);
469 SchemaRef::Schema(Box::new(s))
470 }
471 fn name() -> std::borrow::Cow<'static, str> {
472 std::borrow::Cow::Borrowed("User")
473 }
474 }
475
476 #[test]
477 fn test_link_creation() {
478 let link = Link::new("/users/1")
479 .title("Get user")
480 .media_type("application/json");
481
482 assert_eq!(link.href, "/users/1");
483 assert_eq!(link.title, Some("Get user".to_string()));
484 assert_eq!(link.media_type, Some("application/json".to_string()));
485 }
486
487 #[test]
488 fn test_templated_link() {
489 let link = Link::templated("/users/{id}");
490 assert!(link.templated.unwrap());
491 }
492
493 #[test]
494 fn test_resource_with_links() {
495 let user = User {
496 id: 1,
497 name: "John".to_string(),
498 };
499 let resource = Resource::new(user)
500 .self_link("/users/1")
501 .link("orders", "/users/1/orders");
502
503 assert!(resource.links.contains_key("self"));
504 assert!(resource.links.contains_key("orders"));
505
506 let json = serde_json::to_string_pretty(&resource).unwrap();
507 assert!(json.contains("_links"));
508 assert!(json.contains("/users/1"));
509 }
510
511 #[test]
512 fn test_resource_collection() {
513 let users = vec![
514 User {
515 id: 1,
516 name: "John".to_string(),
517 },
518 User {
519 id: 2,
520 name: "Jane".to_string(),
521 },
522 ];
523
524 let page = PageInfo::calculate(100, 20, 2);
525 let collection = ResourceCollection::new("users", users)
526 .page_info(page)
527 .with_pagination("/api/users");
528
529 assert!(collection.links.contains_key("self"));
530 assert!(collection.links.contains_key("first"));
531 assert!(collection.links.contains_key("prev"));
532 assert!(collection.links.contains_key("next"));
533 }
534
535 #[test]
536 fn test_page_info_calculation() {
537 let page = PageInfo::calculate(95, 20, 0);
538 assert_eq!(page.total_pages, 5);
539 assert_eq!(page.size, 20);
540 }
541
542 #[test]
543 fn test_linkable_trait() {
544 let user = User {
545 id: 1,
546 name: "Test".to_string(),
547 };
548 let resource = user.with_links().self_link("/users/1");
549 assert!(resource.links.contains_key("self"));
550 }
551}