Skip to main content

tango/
internal.rs

1//! Internal helpers shared across resource modules.
2//!
3//! [`ListOptions`] is the shared pagination/shaping field set every list
4//! endpoint accepts. Resource-specific option types compose it via the
5//! `#[serde(flatten)] pagination: ListOptions` pattern, so callers don't have
6//! to repeat `page` / `limit` / `cursor` per resource.
7
8use bon::Builder;
9
10/// Pagination, shape, and flattening options common to every list endpoint.
11///
12/// Resource-specific option builders compose this via a `pagination` field.
13/// Constructing one directly is rarely useful; use the resource builder
14/// (e.g. `ListContractsOptions::builder()`) and call `.page(...)`,
15/// `.limit(...)`, `.cursor(...)`, `.shape(...)`, etc. on it.
16#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
17#[non_exhaustive]
18pub struct ListOptions {
19    /// 1-based page number for offset-paginated endpoints. Mutually exclusive
20    /// with [`cursor`](Self::cursor); when both are set, the cursor wins.
21    #[builder(into)]
22    pub page: Option<u32>,
23
24    /// Page size. The server caps this at 100 on most endpoints.
25    #[builder(into)]
26    pub limit: Option<u32>,
27
28    /// Keyset cursor for cursor-paginated endpoints. Pass the `cursor` field
29    /// from the previous [`Page`](crate::Page).
30    #[builder(into)]
31    pub cursor: Option<String>,
32
33    /// Comma-separated field selector for dynamic response shaping. Use one of
34    /// the `SHAPE_*` constants or roll your own.
35    #[builder(into)]
36    pub shape: Option<String>,
37
38    /// When `true`, collapse nested objects into dot-separated keys.
39    #[builder(default)]
40    pub flat: bool,
41
42    /// When `true` (and [`flat`](Self::flat) is also `true`), flatten
43    /// list-valued nested fields as well.
44    #[builder(default)]
45    pub flat_lists: bool,
46}
47
48impl ListOptions {
49    /// Apply this option set to a query-pair list. Resource methods call this
50    /// then layer their resource-specific filters on top.
51    pub(crate) fn apply(&self, q: &mut Vec<(String, String)>) {
52        apply_pagination(
53            q,
54            self.page,
55            self.limit,
56            self.cursor.as_deref(),
57            self.shape.as_deref(),
58            self.flat,
59            self.flat_lists,
60        );
61    }
62}
63
64/// Apply pagination + shape + flat fields to a query-pair list. Used by every
65/// resource module that flattens those fields onto its options builder.
66pub(crate) fn apply_pagination(
67    q: &mut Vec<(String, String)>,
68    page: Option<u32>,
69    limit: Option<u32>,
70    cursor: Option<&str>,
71    shape: Option<&str>,
72    flat: bool,
73    flat_lists: bool,
74) {
75    if let Some(c) = cursor.filter(|s| !s.is_empty()) {
76        q.push(("cursor".into(), c.into()));
77    } else if let Some(p) = page.filter(|p| *p > 0) {
78        q.push(("page".into(), p.to_string()));
79    }
80    if let Some(l) = limit.filter(|l| *l > 0) {
81        q.push(("limit".into(), l.to_string()));
82    }
83    if let Some(s) = shape.filter(|s| !s.is_empty()) {
84        q.push(("shape".into(), s.into()));
85    }
86    if flat {
87        q.push(("flat".into(), "true".into()));
88    }
89    if flat_lists {
90        q.push(("flat_lists".into(), "true".into()));
91    }
92}
93
94/// Push a `(key, value)` pair when `value` is `Some` and non-empty.
95pub(crate) fn push_opt(q: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
96    if let Some(v) = value.filter(|v| !v.is_empty()) {
97        q.push((key.into(), v.into()));
98    }
99}
100
101/// Push a `(key, value)` pair when `value` is `Some(true|false)`.
102pub(crate) fn push_opt_bool(q: &mut Vec<(String, String)>, key: &str, value: Option<bool>) {
103    if let Some(v) = value {
104        q.push((key.into(), v.to_string()));
105    }
106}
107
108/// Push a `(key, value)` pair when `value` is `Some(n)` and non-zero.
109pub(crate) fn push_opt_u32(q: &mut Vec<(String, String)>, key: &str, value: Option<u32>) {
110    if let Some(v) = value.filter(|n| *n > 0) {
111        q.push((key.into(), v.to_string()));
112    }
113}
114
115/// Pick the first non-empty string from a slice of optional borrows.
116pub(crate) fn first_non_empty<'a>(values: &[Option<&'a str>]) -> Option<&'a str> {
117    values.iter().copied().flatten().find(|s| !s.is_empty())
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn list_options_cursor_wins_over_page() {
126        let opts = ListOptions::builder()
127            .page(2u32)
128            .cursor("abc".to_string())
129            .build();
130        let mut q = Vec::new();
131        opts.apply(&mut q);
132        assert!(q.contains(&("cursor".into(), "abc".into())));
133        assert!(!q.iter().any(|(k, _)| k == "page"));
134    }
135
136    #[test]
137    fn list_options_only_emits_set_fields() {
138        let opts = ListOptions::builder().limit(25u32).build();
139        let mut q = Vec::new();
140        opts.apply(&mut q);
141        assert_eq!(q, vec![("limit".to_string(), "25".to_string())]);
142    }
143
144    #[test]
145    fn list_options_flat_emits_string() {
146        let opts = ListOptions::builder().flat(true).flat_lists(true).build();
147        let mut q = Vec::new();
148        opts.apply(&mut q);
149        assert!(q.contains(&("flat".into(), "true".into())));
150        assert!(q.contains(&("flat_lists".into(), "true".into())));
151    }
152
153    #[test]
154    fn first_non_empty_skips_empties() {
155        assert_eq!(
156            first_non_empty(&[Some(""), Some("a"), Some("b")]),
157            Some("a")
158        );
159        assert_eq!(first_non_empty(&[None, None]), None);
160        assert_eq!(first_non_empty(&[Some("")]), None);
161    }
162}