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