use rustapi_openapi::Schema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Schema)]
pub struct Link {
pub href: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub templated: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub media_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deprecation: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hreflang: Option<String>,
}
impl Link {
pub fn new(href: impl Into<String>) -> Self {
Self {
href: href.into(),
templated: None,
title: None,
media_type: None,
deprecation: None,
name: None,
profile: None,
hreflang: None,
}
}
pub fn templated(href: impl Into<String>) -> Self {
Self {
href: href.into(),
templated: Some(true),
..Self::new("")
}
}
pub fn set_templated(mut self, templated: bool) -> Self {
self.templated = Some(templated);
self
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn media_type(mut self, media_type: impl Into<String>) -> Self {
self.media_type = Some(media_type.into());
self
}
pub fn deprecation(mut self, deprecation_url: impl Into<String>) -> Self {
self.deprecation = Some(deprecation_url.into());
self
}
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn profile(mut self, profile: impl Into<String>) -> Self {
self.profile = Some(profile.into());
self
}
pub fn hreflang(mut self, hreflang: impl Into<String>) -> Self {
self.hreflang = Some(hreflang.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Schema)]
pub struct Resource<T: rustapi_openapi::schema::RustApiSchema> {
#[serde(flatten)]
pub data: T,
#[serde(rename = "_links")]
pub links: HashMap<String, LinkOrArray>,
#[serde(rename = "_embedded", skip_serializing_if = "Option::is_none")]
pub embedded: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Schema)]
#[serde(untagged)]
pub enum LinkOrArray {
Single(Link),
Array(Vec<Link>),
}
impl From<Link> for LinkOrArray {
fn from(link: Link) -> Self {
LinkOrArray::Single(link)
}
}
impl From<Vec<Link>> for LinkOrArray {
fn from(links: Vec<Link>) -> Self {
LinkOrArray::Array(links)
}
}
impl<T: rustapi_openapi::schema::RustApiSchema> Resource<T> {
pub fn new(data: T) -> Self {
Self {
data,
links: HashMap::new(),
embedded: None,
}
}
pub fn link(mut self, rel: impl Into<String>, href: impl Into<String>) -> Self {
self.links
.insert(rel.into(), LinkOrArray::Single(Link::new(href)));
self
}
pub fn link_object(mut self, rel: impl Into<String>, link: Link) -> Self {
self.links.insert(rel.into(), LinkOrArray::Single(link));
self
}
pub fn links(mut self, rel: impl Into<String>, links: Vec<Link>) -> Self {
self.links.insert(rel.into(), LinkOrArray::Array(links));
self
}
pub fn self_link(self, href: impl Into<String>) -> Self {
self.link("self", href)
}
pub fn embed<E: Serialize>(
mut self,
rel: impl Into<String>,
resources: E,
) -> Result<Self, serde_json::Error> {
let embedded = self.embedded.get_or_insert_with(HashMap::new);
embedded.insert(rel.into(), serde_json::to_value(resources)?);
Ok(self)
}
pub fn embed_raw(mut self, rel: impl Into<String>, value: serde_json::Value) -> Self {
let embedded = self.embedded.get_or_insert_with(HashMap::new);
embedded.insert(rel.into(), value);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Schema)]
pub struct ResourceCollection<T: rustapi_openapi::schema::RustApiSchema> {
#[serde(rename = "_embedded")]
pub embedded: HashMap<String, Vec<T>>,
#[serde(rename = "_links")]
pub links: HashMap<String, LinkOrArray>,
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<PageInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Schema)]
pub struct PageInfo {
pub size: usize,
#[serde(rename = "totalElements")]
pub total_elements: usize,
#[serde(rename = "totalPages")]
pub total_pages: usize,
pub number: usize,
}
impl PageInfo {
pub fn new(size: usize, total_elements: usize, total_pages: usize, number: usize) -> Self {
Self {
size,
total_elements,
total_pages,
number,
}
}
pub fn calculate(total_elements: usize, page_size: usize, current_page: usize) -> Self {
let total_pages = total_elements.div_ceil(page_size);
Self {
size: page_size,
total_elements,
total_pages,
number: current_page,
}
}
}
impl<T: rustapi_openapi::schema::RustApiSchema> ResourceCollection<T> {
pub fn new(rel: impl Into<String>, items: Vec<T>) -> Self {
let mut embedded = HashMap::new();
embedded.insert(rel.into(), items);
Self {
embedded,
links: HashMap::new(),
page: None,
}
}
pub fn link(mut self, rel: impl Into<String>, href: impl Into<String>) -> Self {
self.links
.insert(rel.into(), LinkOrArray::Single(Link::new(href)));
self
}
pub fn self_link(self, href: impl Into<String>) -> Self {
self.link("self", href)
}
pub fn first_link(self, href: impl Into<String>) -> Self {
self.link("first", href)
}
pub fn last_link(self, href: impl Into<String>) -> Self {
self.link("last", href)
}
pub fn next_link(self, href: impl Into<String>) -> Self {
self.link("next", href)
}
pub fn prev_link(self, href: impl Into<String>) -> Self {
self.link("prev", href)
}
pub fn page_info(mut self, page: PageInfo) -> Self {
self.page = Some(page);
self
}
pub fn with_pagination(mut self, base_url: &str) -> Self {
let page_info = self.page.clone();
if let Some(page) = page_info {
self = self.self_link(format!(
"{}?page={}&size={}",
base_url, page.number, page.size
));
self = self.first_link(format!("{}?page=0&size={}", base_url, page.size));
if page.total_pages > 0 {
self = self.last_link(format!(
"{}?page={}&size={}",
base_url,
page.total_pages - 1,
page.size
));
}
if page.number > 0 {
self = self.prev_link(format!(
"{}?page={}&size={}",
base_url,
page.number - 1,
page.size
));
}
if page.number < page.total_pages.saturating_sub(1) {
self = self.next_link(format!(
"{}?page={}&size={}",
base_url,
page.number + 1,
page.size
));
}
}
self
}
}
pub trait Linkable: Sized + Serialize + rustapi_openapi::schema::RustApiSchema {
fn with_links(self) -> Resource<Self> {
Resource::new(self)
}
}
impl<T: Serialize + rustapi_openapi::schema::RustApiSchema> Linkable for T {}
#[derive(Debug, Clone)]
pub struct Paginated<T> {
pub items: Vec<T>,
pub page: u64,
pub per_page: u64,
pub total: u64,
}
impl<T> Paginated<T> {
pub fn new(items: Vec<T>, page: u64, per_page: u64, total: u64) -> Self {
Self {
items,
page,
per_page,
total,
}
}
pub fn total_pages(&self) -> u64 {
if self.per_page == 0 {
return 0;
}
self.total.div_ceil(self.per_page)
}
pub fn has_next(&self) -> bool {
self.page < self.total_pages()
}
pub fn has_prev(&self) -> bool {
self.page > 1
}
pub fn map<U, F: FnMut(T) -> U>(self, f: F) -> Paginated<U> {
Paginated {
items: self.items.into_iter().map(f).collect(),
page: self.page,
per_page: self.per_page,
total: self.total,
}
}
}
#[derive(Serialize)]
struct PaginatedBody<T: Serialize> {
items: Vec<T>,
meta: PaginationMeta,
#[serde(rename = "_links")]
links: PaginationLinks,
}
#[derive(Serialize)]
struct PaginationMeta {
page: u64,
per_page: u64,
total: u64,
total_pages: u64,
}
#[derive(Serialize)]
struct PaginationLinks {
#[serde(rename = "self")]
self_link: String,
first: String,
last: String,
#[serde(skip_serializing_if = "Option::is_none")]
next: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
prev: Option<String>,
}
impl<T: Serialize> Paginated<T> {
fn link_header(&self, base_path: &str) -> String {
let total_pages = self.total_pages();
let mut links = Vec::new();
links.push(format!(
"<{}?page=1&per_page={}>; rel=\"first\"",
base_path, self.per_page
));
if total_pages > 0 {
links.push(format!(
"<{}?page={}&per_page={}>; rel=\"last\"",
base_path, total_pages, self.per_page
));
}
if self.has_prev() {
links.push(format!(
"<{}?page={}&per_page={}>; rel=\"prev\"",
base_path,
self.page - 1,
self.per_page
));
}
if self.has_next() {
links.push(format!(
"<{}?page={}&per_page={}>; rel=\"next\"",
base_path,
self.page + 1,
self.per_page
));
}
links.join(", ")
}
fn to_body_with_path(&self, base_path: &str) -> PaginatedBody<&T> {
let total_pages = self.total_pages();
let links = PaginationLinks {
self_link: format!(
"{}?page={}&per_page={}",
base_path, self.page, self.per_page
),
first: format!("{}?page=1&per_page={}", base_path, self.per_page),
last: format!(
"{}?page={}&per_page={}",
base_path,
total_pages.max(1),
self.per_page
),
next: if self.has_next() {
Some(format!(
"{}?page={}&per_page={}",
base_path,
self.page + 1,
self.per_page
))
} else {
None
},
prev: if self.has_prev() {
Some(format!(
"{}?page={}&per_page={}",
base_path,
self.page - 1,
self.per_page
))
} else {
None
},
};
PaginatedBody {
items: self.items.iter().collect(),
meta: PaginationMeta {
page: self.page,
per_page: self.per_page,
total: self.total,
total_pages,
},
links,
}
}
}
impl<T: Serialize + Send> crate::response::IntoResponse for Paginated<T> {
fn into_response(self) -> crate::response::Response {
let base_path = "";
let link_header = self.link_header(base_path);
let body = self.to_body_with_path(base_path);
let total_count = self.total.to_string();
match crate::json::to_vec_with_capacity(&body, 512) {
Ok(json_bytes) => {
let mut response = http::Response::builder()
.status(http::StatusCode::OK)
.header(http::header::CONTENT_TYPE, "application/json")
.header("X-Total-Count", &total_count)
.header("X-Total-Pages", self.total_pages().to_string())
.body(crate::response::Body::from(json_bytes))
.unwrap();
if !link_header.is_empty() {
response.headers_mut().insert(
http::header::LINK,
http::HeaderValue::from_str(&link_header)
.unwrap_or_else(|_| http::HeaderValue::from_static("")),
);
}
response
}
Err(err) => crate::error::ApiError::internal(format!(
"Failed to serialize paginated response: {}",
err
))
.into_response(),
}
}
}
#[derive(Debug, Clone)]
pub struct CursorPaginated<T> {
pub items: Vec<T>,
pub next_cursor: Option<String>,
pub has_more: bool,
}
impl<T> CursorPaginated<T> {
pub fn new(items: Vec<T>, next_cursor: Option<String>, has_more: bool) -> Self {
Self {
items,
next_cursor,
has_more,
}
}
pub fn map<U, F: FnMut(T) -> U>(self, f: F) -> CursorPaginated<U> {
CursorPaginated {
items: self.items.into_iter().map(f).collect(),
next_cursor: self.next_cursor,
has_more: self.has_more,
}
}
}
#[derive(Serialize)]
struct CursorPaginatedBody<T: Serialize> {
items: Vec<T>,
meta: CursorMeta,
}
#[derive(Serialize)]
struct CursorMeta {
#[serde(skip_serializing_if = "Option::is_none")]
next_cursor: Option<String>,
has_more: bool,
}
impl<T: Serialize + Send> crate::response::IntoResponse for CursorPaginated<T> {
fn into_response(self) -> crate::response::Response {
let body = CursorPaginatedBody {
items: self.items,
meta: CursorMeta {
next_cursor: self.next_cursor,
has_more: self.has_more,
},
};
match crate::json::to_vec_with_capacity(&body, 512) {
Ok(json_bytes) => http::Response::builder()
.status(http::StatusCode::OK)
.header(http::header::CONTENT_TYPE, "application/json")
.body(crate::response::Body::from(json_bytes))
.unwrap(),
Err(err) => crate::error::ApiError::internal(format!(
"Failed to serialize cursor-paginated response: {}",
err
))
.into_response(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rustapi_openapi::schema::{JsonSchema2020, RustApiSchema, SchemaCtx, SchemaRef};
use serde::Serialize;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct User {
id: i64,
name: String,
}
impl RustApiSchema for User {
fn schema(_: &mut SchemaCtx) -> SchemaRef {
let mut s = JsonSchema2020::object();
let mut props = std::collections::BTreeMap::new();
props.insert("id".to_string(), JsonSchema2020::integer());
props.insert("name".to_string(), JsonSchema2020::string());
s.properties = Some(props);
SchemaRef::Schema(Box::new(s))
}
fn name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("User")
}
}
#[test]
fn test_link_creation() {
let link = Link::new("/users/1")
.title("Get user")
.media_type("application/json");
assert_eq!(link.href, "/users/1");
assert_eq!(link.title, Some("Get user".to_string()));
assert_eq!(link.media_type, Some("application/json".to_string()));
}
#[test]
fn test_templated_link() {
let link = Link::templated("/users/{id}");
assert!(link.templated.unwrap());
}
#[test]
fn test_resource_with_links() {
let user = User {
id: 1,
name: "John".to_string(),
};
let resource = Resource::new(user)
.self_link("/users/1")
.link("orders", "/users/1/orders");
assert!(resource.links.contains_key("self"));
assert!(resource.links.contains_key("orders"));
let json = serde_json::to_string_pretty(&resource).unwrap();
assert!(json.contains("_links"));
assert!(json.contains("/users/1"));
}
#[test]
fn test_resource_collection() {
let users = vec![
User {
id: 1,
name: "John".to_string(),
},
User {
id: 2,
name: "Jane".to_string(),
},
];
let page = PageInfo::calculate(100, 20, 2);
let collection = ResourceCollection::new("users", users)
.page_info(page)
.with_pagination("/api/users");
assert!(collection.links.contains_key("self"));
assert!(collection.links.contains_key("first"));
assert!(collection.links.contains_key("prev"));
assert!(collection.links.contains_key("next"));
}
#[test]
fn test_page_info_calculation() {
let page = PageInfo::calculate(95, 20, 0);
assert_eq!(page.total_pages, 5);
assert_eq!(page.size, 20);
}
#[test]
fn test_linkable_trait() {
let user = User {
id: 1,
name: "Test".to_string(),
};
let resource = user.with_links().self_link("/users/1");
assert!(resource.links.contains_key("self"));
}
}