Skip to main content

tango/resources/
entity_subresources.rs

1//! Entity sub-resources: contracts, IDVs, OTAs, OTIDVs, subawards, LCATs,
2//! metrics.
3//!
4//! Endpoints under `/api/entities/{uei}/…/` that share a common parameter
5//! shape. The Go SDK splits these into `EntitySubresourceOptions`,
6//! `EntitySubawardsOptions`, and `EntityLcatsOptions`; the Rust port
7//! consolidates these behind a single [`EntitySubresourceOptions`] since the
8//! surface SDK params for these endpoints are identical (pagination + shape +
9//! ordering + search + joiner). Subaward callers should pass `ordering`
10//! through verbatim — the server enforces its own allowlist.
11
12use crate::client::Client;
13use crate::error::{Error, Result};
14use crate::internal::{apply_pagination, push_opt};
15use crate::pagination::{FetchFn, Page, PageStream};
16use crate::resources::agencies::urlencoding;
17use crate::Record;
18use bon::Builder;
19use std::collections::BTreeMap;
20use std::sync::Arc;
21
22/// Options for [`Client::get_entity_budget_flows`].
23#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
24#[non_exhaustive]
25pub struct EntityBudgetFlowsOptions {
26    /// 1-based page number.
27    #[builder(into)]
28    pub page: Option<u32>,
29    /// Page size (default 25, server caps at 100).
30    #[builder(into)]
31    pub limit: Option<u32>,
32    /// Restrict to one fiscal year (e.g. `2024`).
33    pub fiscal_year: Option<u32>,
34}
35
36impl EntityBudgetFlowsOptions {
37    fn to_query(&self) -> Vec<(String, String)> {
38        let mut q = Vec::new();
39        apply_pagination(&mut q, self.page, self.limit, None, None, false, false);
40        if let Some(fy) = self.fiscal_year {
41            q.push(("fiscal_year".into(), fy.to_string()));
42        }
43        q
44    }
45}
46
47/// Options shared by every entity sub-resource list endpoint
48/// (`/api/entities/{uei}/contracts/`, `/idvs/`, `/otas/`, `/otidvs/`,
49/// `/subawards/`, `/lcats/`).
50#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
51#[non_exhaustive]
52pub struct EntitySubresourceOptions {
53    /// 1-based page number. Mutually exclusive with [`cursor`](Self::cursor).
54    #[builder(into)]
55    pub page: Option<u32>,
56    /// Page size.
57    #[builder(into)]
58    pub limit: Option<u32>,
59    /// Keyset cursor for cursor-paginated endpoints.
60    #[builder(into)]
61    pub cursor: Option<String>,
62    /// Comma-separated field selector.
63    #[builder(into)]
64    pub shape: Option<String>,
65    /// Collapse nested objects into dot-separated keys.
66    #[builder(default)]
67    pub flat: bool,
68    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
69    #[builder(default)]
70    pub flat_lists: bool,
71    /// Joiner for flattened keys (only sent when `flat=true`).
72    #[builder(into)]
73    pub joiner: Option<String>,
74    /// Server-side sort spec. Endpoint-specific allowlist; prefix `-` for
75    /// descending. The subawards endpoint enforces a stricter allowlist than
76    /// most — pass values verbatim and let the server validate.
77    #[builder(into)]
78    pub ordering: Option<String>,
79    /// Free-text search filter where supported by the endpoint.
80    #[builder(into)]
81    pub search: Option<String>,
82    /// Escape hatch for filter keys not yet first-classed.
83    #[builder(default)]
84    pub extra: BTreeMap<String, String>,
85}
86
87impl EntitySubresourceOptions {
88    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
89        let mut q = Vec::new();
90        apply_pagination(
91            &mut q,
92            self.page,
93            self.limit,
94            self.cursor.as_deref(),
95            self.shape.as_deref(),
96            self.flat,
97            self.flat_lists,
98        );
99        if self.flat {
100            if let Some(j) = self.joiner.as_deref().filter(|s| !s.is_empty()) {
101                q.push(("joiner".into(), j.into()));
102            }
103        }
104        push_opt(&mut q, "ordering", self.ordering.as_deref());
105        push_opt(&mut q, "search", self.search.as_deref());
106        for (k, v) in &self.extra {
107            if !v.is_empty() {
108                q.push((k.clone(), v.clone()));
109            }
110        }
111        q
112    }
113}
114
115impl Client {
116    /// `GET /api/entities/{uei}/contracts/` — contracts awarded to this entity.
117    pub async fn list_entity_contracts(
118        &self,
119        uei: &str,
120        opts: EntitySubresourceOptions,
121    ) -> Result<Page<Record>> {
122        list_entity_subresource(self, uei, "contracts", opts).await
123    }
124
125    /// Stream every contract awarded to `uei`.
126    pub fn iterate_entity_contracts(
127        &self,
128        uei: &str,
129        opts: EntitySubresourceOptions,
130    ) -> PageStream<Record> {
131        iterate_entity_subresource(self, uei.to_string(), "contracts", opts)
132    }
133
134    /// `GET /api/entities/{uei}/idvs/` — IDVs held by this entity.
135    pub async fn list_entity_idvs(
136        &self,
137        uei: &str,
138        opts: EntitySubresourceOptions,
139    ) -> Result<Page<Record>> {
140        list_entity_subresource(self, uei, "idvs", opts).await
141    }
142
143    /// Stream every IDV held by `uei`.
144    pub fn iterate_entity_idvs(
145        &self,
146        uei: &str,
147        opts: EntitySubresourceOptions,
148    ) -> PageStream<Record> {
149        iterate_entity_subresource(self, uei.to_string(), "idvs", opts)
150    }
151
152    /// `GET /api/entities/{uei}/otas/` — Other Transaction Awards held by this
153    /// entity.
154    pub async fn list_entity_otas(
155        &self,
156        uei: &str,
157        opts: EntitySubresourceOptions,
158    ) -> Result<Page<Record>> {
159        list_entity_subresource(self, uei, "otas", opts).await
160    }
161
162    /// Stream every OTA held by `uei`.
163    pub fn iterate_entity_otas(
164        &self,
165        uei: &str,
166        opts: EntitySubresourceOptions,
167    ) -> PageStream<Record> {
168        iterate_entity_subresource(self, uei.to_string(), "otas", opts)
169    }
170
171    /// `GET /api/entities/{uei}/otidvs/` — Other Transaction IDVs held by this
172    /// entity.
173    pub async fn list_entity_otidvs(
174        &self,
175        uei: &str,
176        opts: EntitySubresourceOptions,
177    ) -> Result<Page<Record>> {
178        list_entity_subresource(self, uei, "otidvs", opts).await
179    }
180
181    /// Stream every OTIDV held by `uei`.
182    pub fn iterate_entity_otidvs(
183        &self,
184        uei: &str,
185        opts: EntitySubresourceOptions,
186    ) -> PageStream<Record> {
187        iterate_entity_subresource(self, uei.to_string(), "otidvs", opts)
188    }
189
190    /// `GET /api/entities/{uei}/subawards/` — subawards reported against
191    /// contracts held by this entity.
192    pub async fn list_entity_subawards(
193        &self,
194        uei: &str,
195        opts: EntitySubresourceOptions,
196    ) -> Result<Page<Record>> {
197        list_entity_subresource(self, uei, "subawards", opts).await
198    }
199
200    /// Stream every subaward reported under `uei`.
201    pub fn iterate_entity_subawards(
202        &self,
203        uei: &str,
204        opts: EntitySubresourceOptions,
205    ) -> PageStream<Record> {
206        iterate_entity_subresource(self, uei.to_string(), "subawards", opts)
207    }
208
209    /// `GET /api/entities/{uei}/lcats/` — Labor Categories advertised by this
210    /// entity.
211    pub async fn list_entity_lcats(
212        &self,
213        uei: &str,
214        opts: EntitySubresourceOptions,
215    ) -> Result<Page<Record>> {
216        list_entity_subresource(self, uei, "lcats", opts).await
217    }
218
219    /// Stream every LCAT advertised by `uei`.
220    pub fn iterate_entity_lcats(
221        &self,
222        uei: &str,
223        opts: EntitySubresourceOptions,
224    ) -> PageStream<Record> {
225        iterate_entity_subresource(self, uei.to_string(), "lcats", opts)
226    }
227
228    /// `GET /api/entities/{uei}/budget-flows/` — the federal accounts that paid this entity, largest `contract_obligated` first, each with its budget-account context.
229    ///
230    /// Contract flows only: grant and assistance flows are not in this index.
231    pub async fn get_entity_budget_flows(
232        &self,
233        uei: &str,
234        opts: Option<EntityBudgetFlowsOptions>,
235    ) -> Result<Page<Record>> {
236        if uei.is_empty() {
237            return Err(Error::Validation {
238                message: "get_entity_budget_flows: uei is required".into(),
239                response: None,
240            });
241        }
242        let q = opts.unwrap_or_default().to_query();
243        let path = format!("/api/entities/{}/budget-flows/", urlencoding(uei));
244        let bytes = self.get_bytes(&path, &q).await?;
245        Page::decode(&bytes)
246    }
247
248    /// `GET /api/entities/{uei}/metrics/{months}/{period_grouping}/` — rolling
249    /// windowed metrics for this entity. Mirrors the signature of the sibling
250    /// SDKs (Node / Python / Go).
251    ///
252    /// `months` is the rolling-window length (must be > 0). `period_grouping`
253    /// is typically `"month"` or `"quarter"`.
254    pub async fn get_entity_metrics(
255        &self,
256        uei: &str,
257        months: u32,
258        period_grouping: &str,
259    ) -> Result<Record> {
260        if uei.is_empty() {
261            return Err(Error::Validation {
262                message: "get_entity_metrics: uei is required".into(),
263                response: None,
264            });
265        }
266        if months == 0 {
267            return Err(Error::Validation {
268                message: "get_entity_metrics: months must be > 0".into(),
269                response: None,
270            });
271        }
272        if period_grouping.is_empty() {
273            return Err(Error::Validation {
274                message: "get_entity_metrics: period_grouping is required".into(),
275                response: None,
276            });
277        }
278        let path = format!(
279            "/api/entities/{}/metrics/{}/{}/",
280            urlencoding(uei),
281            months,
282            urlencoding(period_grouping),
283        );
284        self.get_json::<Record>(&path, &[]).await
285    }
286}
287
288async fn list_entity_subresource(
289    client: &Client,
290    uei: &str,
291    segment: &str,
292    opts: EntitySubresourceOptions,
293) -> Result<Page<Record>> {
294    if uei.is_empty() {
295        return Err(Error::Validation {
296            message: "entity sub-resource: uei is required".into(),
297            response: None,
298        });
299    }
300    let q = opts.to_query();
301    let path = format!("/api/entities/{}/{segment}/", urlencoding(uei));
302    let bytes = client.get_bytes(&path, &q).await?;
303    Page::decode(&bytes)
304}
305
306fn iterate_entity_subresource(
307    client: &Client,
308    uei: String,
309    segment: &'static str,
310    opts: EntitySubresourceOptions,
311) -> PageStream<Record> {
312    let opts = Arc::new(opts);
313    let uei = Arc::new(uei);
314    let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
315        let mut next = (*opts).clone();
316        next.page = page;
317        next.cursor = cursor;
318        let uei = uei.clone();
319        Box::pin(async move { list_entity_subresource(&client, &uei, segment, next).await })
320    });
321    PageStream::new(client.clone(), fetch)
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
329        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
330    }
331
332    #[test]
333    fn options_emit_pagination_shape_and_search() {
334        let opts = EntitySubresourceOptions::builder()
335            .limit(10u32)
336            .shape("contracts(minimal)")
337            .ordering("-award_date")
338            .search("software")
339            .build();
340        let q = opts.to_query();
341        assert_eq!(get_q(&q, "limit").as_deref(), Some("10"));
342        assert_eq!(get_q(&q, "shape").as_deref(), Some("contracts(minimal)"));
343        assert_eq!(get_q(&q, "ordering").as_deref(), Some("-award_date"));
344        assert_eq!(get_q(&q, "search").as_deref(), Some("software"));
345        assert!(!q.iter().any(|(k, _)| k == "joiner"));
346    }
347
348    #[test]
349    fn joiner_only_when_flat() {
350        let opts = EntitySubresourceOptions::builder()
351            .joiner("__".to_string())
352            .build();
353        let q = opts.to_query();
354        assert!(!q.iter().any(|(k, _)| k == "joiner"));
355
356        let opts = EntitySubresourceOptions::builder()
357            .flat(true)
358            .joiner("__".to_string())
359            .build();
360        let q = opts.to_query();
361        assert!(q.contains(&("joiner".into(), "__".into())));
362    }
363
364    #[test]
365    fn extra_forwards_arbitrary_params() {
366        let mut extra = BTreeMap::new();
367        extra.insert("custom_x".to_string(), "val".to_string());
368        let opts = EntitySubresourceOptions::builder().extra(extra).build();
369        let q = opts.to_query();
370        assert!(q.contains(&("custom_x".into(), "val".into())));
371    }
372
373    #[tokio::test]
374    async fn list_entity_contracts_empty_uei_returns_validation() {
375        let client = Client::builder().api_key("x").build().expect("build");
376        let err = client
377            .list_entity_contracts("", EntitySubresourceOptions::default())
378            .await
379            .expect_err("must error");
380        match err {
381            Error::Validation { message, .. } => assert!(message.contains("uei")),
382            other => panic!("expected Validation, got {other:?}"),
383        }
384    }
385
386    #[tokio::test]
387    async fn list_entity_subawards_empty_uei_returns_validation() {
388        let client = Client::builder().api_key("x").build().expect("build");
389        let err = client
390            .list_entity_subawards("", EntitySubresourceOptions::default())
391            .await
392            .expect_err("must error");
393        match err {
394            Error::Validation { message, .. } => assert!(message.contains("uei")),
395            other => panic!("expected Validation, got {other:?}"),
396        }
397    }
398
399    #[test]
400    fn budget_flows_options_emit_fiscal_year() {
401        let q = EntityBudgetFlowsOptions::builder()
402            .fiscal_year(2024u32)
403            .limit(10u32)
404            .build()
405            .to_query();
406        assert_eq!(
407            q,
408            vec![
409                ("limit".to_string(), "10".to_string()),
410                ("fiscal_year".to_string(), "2024".to_string())
411            ]
412        );
413    }
414
415    #[tokio::test]
416    async fn get_entity_budget_flows_empty_uei_returns_validation() {
417        let client = Client::builder().api_key("x").build().expect("build");
418        let err = client
419            .get_entity_budget_flows("", None)
420            .await
421            .expect_err("must error");
422        match err {
423            Error::Validation { message, .. } => assert!(message.contains("uei")),
424            other => panic!("expected Validation, got {other:?}"),
425        }
426    }
427
428    #[tokio::test]
429    async fn get_entity_metrics_empty_uei_returns_validation() {
430        let client = Client::builder().api_key("x").build().expect("build");
431        let err = client
432            .get_entity_metrics("", 12, "month")
433            .await
434            .expect_err("must error");
435        match err {
436            Error::Validation { message, .. } => assert!(message.contains("uei")),
437            other => panic!("expected Validation, got {other:?}"),
438        }
439    }
440
441    #[tokio::test]
442    async fn get_entity_metrics_zero_months_returns_validation() {
443        let client = Client::builder().api_key("x").build().expect("build");
444        let err = client
445            .get_entity_metrics("ABC123DEF456", 0, "month")
446            .await
447            .expect_err("must error");
448        assert!(matches!(err, Error::Validation { .. }));
449    }
450
451    #[tokio::test]
452    async fn get_entity_metrics_empty_period_grouping_returns_validation() {
453        let client = Client::builder().api_key("x").build().expect("build");
454        let err = client
455            .get_entity_metrics("ABC123DEF456", 12, "")
456            .await
457            .expect_err("must error");
458        assert!(matches!(err, Error::Validation { .. }));
459    }
460}