Skip to main content

modkit_odata/
page.rs

1use serde::{Deserialize, Serialize};
2
3#[cfg_attr(feature = "with-utoipa", derive(utoipa::ToSchema))]
4#[derive(Clone, Debug, Serialize, Deserialize)]
5pub struct PageInfo {
6    pub next_cursor: Option<String>,
7    pub prev_cursor: Option<String>,
8    pub limit: u64,
9}
10
11#[derive(Clone, Debug, Serialize, Deserialize)]
12pub struct Page<T> {
13    pub items: Vec<T>,
14    pub page_info: PageInfo,
15}
16
17/// Manual [`utoipa::PartialSchema`] impl for `Page<T>`.
18///
19/// utoipa's derive macro for generic structs omits the generic parameter `T`
20/// from the `schemas()` dependency list, producing a dangling `$ref`.
21/// This hand-written impl ensures `T`'s schema is always registered.
22#[cfg(feature = "with-utoipa")]
23impl<T> utoipa::PartialSchema for Page<T>
24where
25    T: utoipa::ToSchema + utoipa::PartialSchema,
26{
27    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
28        use utoipa::openapi::schema::{ArrayBuilder, ObjectBuilder};
29
30        ObjectBuilder::new()
31            .property(
32                "items",
33                ArrayBuilder::new().items(
34                    utoipa::openapi::RefOr::<utoipa::openapi::schema::Schema>::Ref(
35                        utoipa::openapi::Ref::from_schema_name(T::name().to_string()),
36                    ),
37                ),
38            )
39            .required("items")
40            .property(
41                "page_info",
42                utoipa::openapi::RefOr::<utoipa::openapi::schema::Schema>::Ref(
43                    utoipa::openapi::Ref::from_schema_name(
44                        <PageInfo as utoipa::ToSchema>::name().to_string(),
45                    ),
46                ),
47            )
48            .required("page_info")
49            .into()
50    }
51}
52
53#[cfg(feature = "with-utoipa")]
54impl<T> utoipa::ToSchema for Page<T>
55where
56    T: utoipa::ToSchema + utoipa::PartialSchema,
57{
58    fn name() -> std::borrow::Cow<'static, str> {
59        std::borrow::Cow::Owned(format!("Page_{}", T::name()))
60    }
61
62    fn schemas(
63        schemas: &mut Vec<(
64            String,
65            utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
66        )>,
67    ) {
68        // PageInfo (same as derive)
69        schemas.push((
70            <PageInfo as utoipa::ToSchema>::name().to_string(),
71            <PageInfo as utoipa::PartialSchema>::schema(),
72        ));
73        <PageInfo as utoipa::ToSchema>::schemas(schemas);
74
75        // T — the generic parameter (this is what the derive omits)
76        schemas.push((
77            <T as utoipa::ToSchema>::name().to_string(),
78            <T as utoipa::PartialSchema>::schema(),
79        ));
80        <T as utoipa::ToSchema>::schemas(schemas);
81    }
82}
83
84impl<T> Page<T> {
85    /// Create a new page with items and page info
86    #[must_use]
87    pub fn new(items: Vec<T>, page_info: PageInfo) -> Self {
88        Self { items, page_info }
89    }
90
91    /// Create an empty page with the given limit
92    #[must_use]
93    pub fn empty(limit: u64) -> Self {
94        Self {
95            items: Vec::new(),
96            page_info: PageInfo {
97                next_cursor: None,
98                prev_cursor: None,
99                limit,
100            },
101        }
102    }
103
104    /// Map items while preserving `page_info` (Domain->DTO mapping convenience)
105    pub fn map_items<U>(self, mut f: impl FnMut(T) -> U) -> Page<U> {
106        Page {
107            items: self.items.into_iter().map(&mut f).collect(),
108            page_info: self.page_info,
109        }
110    }
111}
112
113#[cfg(all(test, feature = "with-utoipa"))]
114#[cfg_attr(coverage_nightly, coverage(off))]
115mod tests {
116    use super::*;
117
118    /// Dummy type to verify `Page<T>::schemas()` includes `T`'s schema.
119    #[derive(utoipa::ToSchema)]
120    struct DummyItem {
121        #[allow(dead_code)]
122        pub value: String,
123    }
124
125    #[test]
126    fn test_page_name_includes_generic() {
127        use utoipa::ToSchema;
128        let name = <Page<DummyItem> as ToSchema>::name();
129        assert_eq!(name.as_ref(), "Page_DummyItem");
130    }
131
132    #[test]
133    fn test_page_schemas_includes_inner_type() {
134        use utoipa::ToSchema;
135        let mut schemas = Vec::new();
136        <Page<DummyItem> as ToSchema>::schemas(&mut schemas);
137
138        let names: Vec<&str> = schemas.iter().map(|(n, _)| n.as_str()).collect();
139        assert!(
140            names.contains(&"DummyItem"),
141            "Expected DummyItem in schemas, got: {names:?}"
142        );
143        assert!(
144            names.contains(&"PageInfo"),
145            "Expected PageInfo in schemas, got: {names:?}"
146        );
147    }
148}