Skip to main content

ignition_core/client/
query.rs

1//! The standard 8.3 list envelope + query (02-RESEARCH §Verified Endpoint
2//! Catalog): every list-capable endpoint takes the same
3//! `limit/offset/sortBy/search/filter` params and answers
4//! `{items, metadata}` — ONE generic pair covers them all.
5//!
6//! `limit = -1` is the UI's "everything" convention (observed: unset
7//! limit behaves as -1). Serde is deliberately tolerant: `metadata` may
8//! carry extra keys (e.g. `metrics`) and omit fields — no
9//! `deny_unknown_fields` anywhere, every metadata field carries
10//! `#[serde(default)]`.
11
12use serde::{Deserialize, Serialize};
13
14/// The standard query params every 8.3 list endpoint accepts.
15///
16/// `Default` is the UI convention: `limit = -1` (all items), `offset = 0`,
17/// no sort/search/filter.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19pub struct ListQuery {
20    /// Max items; `-1` = all (the gateway UI's convention).
21    pub limit: i64,
22    /// Skip the first `offset` items.
23    pub offset: i64,
24    /// Server-side sort, when used.
25    pub sort_by: Option<String>,
26    /// Server-side substring search, when used.
27    pub search: Option<String>,
28    /// Server-side filter expression, when used.
29    pub filter: Option<String>,
30}
31
32impl Default for ListQuery {
33    fn default() -> Self {
34        Self {
35            limit: -1,
36            offset: 0,
37            sort_by: None,
38            search: None,
39            filter: None,
40        }
41    }
42}
43
44impl ListQuery {
45    /// Serialize into query pairs — `limit`/`offset` always present
46    /// (explicit beats the gateway's unset-default ambiguity), optional
47    /// keys only when `Some` (absent optionals are skipped, never sent
48    /// as `null`/empty).
49    pub fn to_query_pairs(&self) -> Vec<(String, String)> {
50        let mut pairs = Vec::with_capacity(5);
51        pairs.push(("limit".to_string(), self.limit.to_string()));
52        pairs.push(("offset".to_string(), self.offset.to_string()));
53        if let Some(sort_by) = &self.sort_by {
54            pairs.push(("sortBy".to_string(), sort_by.clone()));
55        }
56        if let Some(search) = &self.search {
57            pairs.push(("search".to_string(), search.clone()));
58        }
59        if let Some(filter) = &self.filter {
60            pairs.push(("filter".to_string(), filter.clone()));
61        }
62        pairs
63    }
64}
65
66/// The `{items, metadata}` envelope every 8.3 list endpoint answers with.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ListEnvelope<T> {
69    /// The page of items.
70    pub items: Vec<T>,
71    /// Pagination metadata.
72    pub metadata: ListMetadata,
73}
74
75/// The `metadata` block of the list envelope (tolerant: fields may be
76/// absent, extra keys like `metrics` are ignored).
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct ListMetadata {
79    /// Total items the collection holds.
80    #[serde(default)]
81    pub total: i64,
82    /// Items matching the current query.
83    #[serde(default)]
84    pub matching: i64,
85    /// Effective limit (`-1` = unlimited).
86    #[serde(default)]
87    pub limit: i64,
88    /// Offset the page starts at.
89    #[serde(default)]
90    pub offset: i64,
91}
92
93#[cfg(test)]
94mod tests {
95    use super::{ListEnvelope, ListMetadata, ListQuery};
96
97    /// Default = the UI convention; query pairs always carry limit=-1 and
98    /// skip absent optionals entirely.
99    #[test]
100    fn query_pairs_skip_absent_optionals() {
101        let pairs = ListQuery::default().to_query_pairs();
102        assert_eq!(
103            pairs,
104            vec![
105                ("limit".to_string(), "-1".to_string()),
106                ("offset".to_string(), "0".to_string()),
107            ],
108            "default carries only limit=-1 and offset=0"
109        );
110
111        let full = ListQuery {
112            limit: 200,
113            offset: 40,
114            sort_by: Some("timestamp".into()),
115            search: Some("GatewayManager".into()),
116            filter: None,
117        };
118        let pairs = full.to_query_pairs();
119        assert_eq!(
120            pairs,
121            vec![
122                ("limit".to_string(), "200".to_string()),
123                ("offset".to_string(), "40".to_string()),
124                ("sortBy".to_string(), "timestamp".to_string()),
125                ("search".to_string(), "GatewayManager".to_string()),
126            ],
127            "present optionals serialize under their gateway-native names; filter skipped"
128        );
129    }
130
131    /// The envelope tolerates extra metadata keys (live bodies carry
132    /// `metrics`) and absent metadata fields.
133    #[test]
134    fn envelope_is_serde_tolerant() {
135        let body = serde_json::json!({
136            "items": [{"id": "mod-1"}],
137            "metadata": {
138                "total": 368,
139                "matching": 368,
140                "limit": -1,
141                "offset": 0,
142                "metrics": {"elapsedMs": 12}
143            }
144        });
145        let page: ListEnvelope<serde_json::Value> =
146            serde_json::from_value(body).expect("extra `metrics` key is tolerated");
147        assert_eq!(page.items.len(), 1);
148        assert_eq!(
149            page.metadata,
150            ListMetadata {
151                total: 368,
152                matching: 368,
153                limit: -1,
154                offset: 0,
155            }
156        );
157
158        let sparse = serde_json::json!({"items": [], "metadata": {}});
159        let page: ListEnvelope<serde_json::Value> =
160            serde_json::from_value(sparse).expect("absent metadata fields default");
161        assert_eq!(page.metadata.total, 0);
162    }
163}