Skip to main content

tango/resources/
itdashboard.rs

1//! `GET /api/itdashboard/` — federal IT investments from the
2//! IT Dashboard.
3
4use crate::client::Client;
5use crate::error::{Error, Result};
6use crate::internal::{apply_pagination, push_opt};
7use crate::pagination::{FetchFn, Page, PageStream};
8use crate::resources::agencies::urlencoding;
9use crate::Record;
10use bon::Builder;
11use std::collections::BTreeMap;
12use std::sync::Arc;
13
14/// Options for [`Client::list_itdashboard`] and [`Client::iterate_itdashboard`].
15///
16/// Filters are tier-gated by the API:
17/// - **Free:** [`search`](Self::search)
18/// - **Pro:** [`agency_code`](Self::agency_code), [`type_of_investment`](Self::type_of_investment),
19///   [`updated_time_after`](Self::updated_time_after), [`updated_time_before`](Self::updated_time_before)
20/// - **Business+:** [`agency_name`](Self::agency_name), [`cio_rating`](Self::cio_rating),
21///   [`cio_rating_max`](Self::cio_rating_max), [`performance_risk`](Self::performance_risk)
22///
23/// Hitting a gated filter on a lower tier returns a 403. CIO ratings:
24/// 1=High Risk, 2=Moderately High, 3=Medium, 4=Moderately Low, 5=Low.
25#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
26#[non_exhaustive]
27pub struct ListItdashboardOptions {
28    /// 1-based page number.
29    #[builder(into)]
30    pub page: Option<u32>,
31    /// Page size (server caps at 100).
32    #[builder(into)]
33    pub limit: Option<u32>,
34    /// Keyset cursor.
35    #[builder(into)]
36    pub cursor: Option<String>,
37    /// Comma-separated field selector. Use
38    /// [`SHAPE_ITDASHBOARD_INVESTMENTS_MINIMAL`](crate::SHAPE_ITDASHBOARD_INVESTMENTS_MINIMAL)
39    /// or roll your own.
40    #[builder(into)]
41    pub shape: Option<String>,
42    /// Collapse nested objects into dot-separated keys.
43    #[builder(default)]
44    pub flat: bool,
45    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
46    #[builder(default)]
47    pub flat_lists: bool,
48
49    /// Free-text search (Free tier).
50    #[builder(into)]
51    pub search: Option<String>,
52    /// Agency code filter (Pro tier).
53    #[builder(into)]
54    pub agency_code: Option<String>,
55    /// Agency name filter (Business+ tier).
56    #[builder(into)]
57    pub agency_name: Option<String>,
58    /// Investment type filter (Pro tier).
59    #[builder(into)]
60    pub type_of_investment: Option<String>,
61
62    /// Lower bound on `updated_time` (Pro tier, ISO 8601).
63    #[builder(into)]
64    pub updated_time_after: Option<String>,
65    /// Upper bound on `updated_time` (Pro tier, ISO 8601).
66    #[builder(into)]
67    pub updated_time_before: Option<String>,
68
69    /// CIO rating filter (Business+ tier). Stringly typed to disambiguate
70    /// "unset" from numeric zero (the API accepts both numeric and stringified
71    /// integer values).
72    #[builder(into)]
73    pub cio_rating: Option<String>,
74    /// Upper bound on CIO rating (Business+ tier).
75    #[builder(into)]
76    pub cio_rating_max: Option<String>,
77    /// Performance-risk filter (Business+ tier).
78    #[builder(into)]
79    pub performance_risk: Option<String>,
80
81    /// Find the investment(s) that superseded a retired UII (e.g. `015-000000100`).
82    #[builder(into)]
83    pub previous_uii: Option<String>,
84
85    /// Escape hatch for filter keys not yet first-classed on this struct.
86    #[builder(default)]
87    pub extra: BTreeMap<String, String>,
88}
89
90impl ListItdashboardOptions {
91    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
92        let mut q = Vec::new();
93        apply_pagination(
94            &mut q,
95            self.page,
96            self.limit,
97            self.cursor.as_deref(),
98            self.shape.as_deref(),
99            self.flat,
100            self.flat_lists,
101        );
102        push_opt(&mut q, "search", self.search.as_deref());
103        push_opt(&mut q, "previous_uii", self.previous_uii.as_deref());
104        push_opt(&mut q, "agency_code", self.agency_code.as_deref());
105        push_opt(&mut q, "agency_name", self.agency_name.as_deref());
106        push_opt(
107            &mut q,
108            "type_of_investment",
109            self.type_of_investment.as_deref(),
110        );
111        push_opt(
112            &mut q,
113            "updated_time_after",
114            self.updated_time_after.as_deref(),
115        );
116        push_opt(
117            &mut q,
118            "updated_time_before",
119            self.updated_time_before.as_deref(),
120        );
121        push_opt(&mut q, "cio_rating", self.cio_rating.as_deref());
122        push_opt(&mut q, "cio_rating_max", self.cio_rating_max.as_deref());
123        push_opt(&mut q, "performance_risk", self.performance_risk.as_deref());
124        for (k, v) in &self.extra {
125            if !v.is_empty() {
126                q.push((k.clone(), v.clone()));
127            }
128        }
129        q
130    }
131}
132
133/// Options for [`Client::get_itdashboard`].
134#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
135#[non_exhaustive]
136pub struct GetItdashboardOptions {
137    /// Shape selector. When empty, the server returns its comprehensive default.
138    #[builder(into)]
139    pub shape: Option<String>,
140    /// Flatten nested objects into dot-separated keys.
141    #[builder(default)]
142    pub flat: bool,
143    /// When `flat=true`, also flatten list-valued nested fields.
144    #[builder(default)]
145    pub flat_lists: bool,
146}
147
148impl GetItdashboardOptions {
149    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
150        let mut q = Vec::new();
151        push_opt(&mut q, "shape", self.shape.as_deref());
152        if self.flat {
153            q.push(("flat".into(), "true".into()));
154        }
155        if self.flat_lists {
156            q.push(("flat_lists".into(), "true".into()));
157        }
158        q
159    }
160}
161
162impl Client {
163    /// `GET /api/itdashboard/` — one page of federal IT investments.
164    pub async fn list_itdashboard(&self, opts: ListItdashboardOptions) -> Result<Page<Record>> {
165        let q = opts.to_query();
166        let bytes = self.get_bytes("/api/itdashboard/", &q).await?;
167        Page::decode(&bytes)
168    }
169
170    /// `GET /api/itdashboard/{uii}/` — fetch a single investment
171    /// by Unique Investment Identifier (UII).
172    pub async fn get_itdashboard(
173        &self,
174        uii: &str,
175        opts: Option<GetItdashboardOptions>,
176    ) -> Result<Record> {
177        if uii.is_empty() {
178            return Err(Error::Validation {
179                message: "get_itdashboard: uii is required".into(),
180                response: None,
181            });
182        }
183        let q = opts.unwrap_or_default().to_query();
184        let path = format!("/api/itdashboard/{}/", urlencoding(uii));
185        self.get_json::<Record>(&path, &q).await
186    }
187
188    /// Stream every IT Dashboard investment matching `opts`.
189    pub fn iterate_itdashboard(&self, opts: ListItdashboardOptions) -> PageStream<Record> {
190        let opts = Arc::new(opts);
191        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
192            let mut next = (*opts).clone();
193            next.page = page;
194            next.cursor = cursor;
195            Box::pin(async move { client.list_itdashboard(next).await })
196        });
197        PageStream::new(self.clone(), fetch)
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
206        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
207    }
208
209    #[test]
210    fn list_itdashboard_all_filters_emit() {
211        let opts = ListItdashboardOptions::builder()
212            .search("cloud")
213            .agency_code("009")
214            .agency_name("Department of State")
215            .type_of_investment("Major")
216            .updated_time_after("2024-01-01T00:00:00Z")
217            .updated_time_before("2024-12-31T23:59:59Z")
218            .cio_rating("3")
219            .cio_rating_max("5")
220            .performance_risk("2")
221            .build();
222        let q = opts.to_query();
223        assert_eq!(get_q(&q, "search").as_deref(), Some("cloud"));
224        assert_eq!(get_q(&q, "agency_code").as_deref(), Some("009"));
225        assert_eq!(
226            get_q(&q, "agency_name").as_deref(),
227            Some("Department of State")
228        );
229        assert_eq!(get_q(&q, "type_of_investment").as_deref(), Some("Major"));
230        assert_eq!(
231            get_q(&q, "updated_time_after").as_deref(),
232            Some("2024-01-01T00:00:00Z")
233        );
234        assert_eq!(
235            get_q(&q, "updated_time_before").as_deref(),
236            Some("2024-12-31T23:59:59Z")
237        );
238        assert_eq!(get_q(&q, "cio_rating").as_deref(), Some("3"));
239        assert_eq!(get_q(&q, "cio_rating_max").as_deref(), Some("5"));
240        assert_eq!(get_q(&q, "performance_risk").as_deref(), Some("2"));
241    }
242
243    #[test]
244    fn list_itdashboard_zero_value_omitted() {
245        let opts = ListItdashboardOptions::builder().build();
246        let q = opts.to_query();
247        assert!(q.is_empty(), "expected empty query, got {q:?}");
248    }
249
250    #[test]
251    fn list_itdashboard_shape_emits() {
252        let opts = ListItdashboardOptions::builder()
253            .shape(crate::SHAPE_ITDASHBOARD_INVESTMENTS_MINIMAL)
254            .build();
255        let q = opts.to_query();
256        assert_eq!(
257            get_q(&q, "shape").as_deref(),
258            Some(crate::SHAPE_ITDASHBOARD_INVESTMENTS_MINIMAL)
259        );
260    }
261
262    #[test]
263    fn list_itdashboard_cursor_wins_over_page() {
264        let opts = ListItdashboardOptions::builder()
265            .page(3u32)
266            .cursor("c0".to_string())
267            .build();
268        let q = opts.to_query();
269        assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0"));
270        assert_eq!(get_q(&q, "page"), None);
271    }
272
273    #[test]
274    fn list_itdashboard_extra_emits() {
275        let mut extra = BTreeMap::new();
276        extra.insert("custom_x".to_string(), "xv".to_string());
277        let opts = ListItdashboardOptions::builder().extra(extra).build();
278        let q = opts.to_query();
279        assert!(q.contains(&("custom_x".into(), "xv".into())));
280    }
281
282    #[tokio::test]
283    async fn get_itdashboard_validates_empty_uii() {
284        let client = Client::builder().api_key("x").build().expect("client");
285        let err = client.get_itdashboard("", None).await.unwrap_err();
286        match err {
287            Error::Validation { message, .. } => {
288                assert!(message.contains("uii is required"));
289            }
290            other => panic!("expected Validation, got {other:?}"),
291        }
292    }
293
294    #[test]
295    fn list_itdashboard_previous_uii_emits() {
296        let q = ListItdashboardOptions::builder()
297            .previous_uii("015-000000100")
298            .build()
299            .to_query();
300        assert_eq!(get_q(&q, "previous_uii").as_deref(), Some("015-000000100"));
301    }
302}