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        self.get_page(&path, &q).await
245    }
246
247    /// `GET /api/entities/{uei}/metrics/{months}/{period_grouping}/` — rolling
248    /// windowed metrics for this entity. Mirrors the signature of the sibling
249    /// SDKs (Node / Python / Go).
250    ///
251    /// `months` is the rolling-window length (must be > 0). `period_grouping`
252    /// is typically `"month"` or `"quarter"`.
253    pub async fn get_entity_metrics(
254        &self,
255        uei: &str,
256        months: u32,
257        period_grouping: &str,
258    ) -> Result<Record> {
259        if uei.is_empty() {
260            return Err(Error::Validation {
261                message: "get_entity_metrics: uei is required".into(),
262                response: None,
263            });
264        }
265        if months == 0 {
266            return Err(Error::Validation {
267                message: "get_entity_metrics: months must be > 0".into(),
268                response: None,
269            });
270        }
271        if period_grouping.is_empty() {
272            return Err(Error::Validation {
273                message: "get_entity_metrics: period_grouping is required".into(),
274                response: None,
275            });
276        }
277        let path = format!(
278            "/api/entities/{}/metrics/{}/{}/",
279            urlencoding(uei),
280            months,
281            urlencoding(period_grouping),
282        );
283        self.get_json::<Record>(&path, &[]).await
284    }
285}
286
287async fn list_entity_subresource(
288    client: &Client,
289    uei: &str,
290    segment: &str,
291    opts: EntitySubresourceOptions,
292) -> Result<Page<Record>> {
293    if uei.is_empty() {
294        return Err(Error::Validation {
295            message: "entity sub-resource: uei is required".into(),
296            response: None,
297        });
298    }
299    let q = opts.to_query();
300    let path = format!("/api/entities/{}/{segment}/", urlencoding(uei));
301    client.get_page(&path, &q).await
302}
303
304fn iterate_entity_subresource(
305    client: &Client,
306    uei: String,
307    segment: &'static str,
308    opts: EntitySubresourceOptions,
309) -> PageStream<Record> {
310    let opts = Arc::new(opts);
311    let uei = Arc::new(uei);
312    let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
313        let mut next = (*opts).clone();
314        next.page = page;
315        next.cursor = cursor;
316        let uei = uei.clone();
317        Box::pin(async move { list_entity_subresource(&client, &uei, segment, next).await })
318    });
319    PageStream::new(client.clone(), fetch)
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
327        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
328    }
329
330    #[test]
331    fn options_emit_pagination_shape_and_search() {
332        let opts = EntitySubresourceOptions::builder()
333            .limit(10u32)
334            .shape("contracts(minimal)")
335            .ordering("-award_date")
336            .search("software")
337            .build();
338        let q = opts.to_query();
339        assert_eq!(get_q(&q, "limit").as_deref(), Some("10"));
340        assert_eq!(get_q(&q, "shape").as_deref(), Some("contracts(minimal)"));
341        assert_eq!(get_q(&q, "ordering").as_deref(), Some("-award_date"));
342        assert_eq!(get_q(&q, "search").as_deref(), Some("software"));
343        assert!(!q.iter().any(|(k, _)| k == "joiner"));
344    }
345
346    #[test]
347    fn joiner_only_when_flat() {
348        let opts = EntitySubresourceOptions::builder()
349            .joiner("__".to_string())
350            .build();
351        let q = opts.to_query();
352        assert!(!q.iter().any(|(k, _)| k == "joiner"));
353
354        let opts = EntitySubresourceOptions::builder()
355            .flat(true)
356            .joiner("__".to_string())
357            .build();
358        let q = opts.to_query();
359        assert!(q.contains(&("joiner".into(), "__".into())));
360    }
361
362    #[test]
363    fn extra_forwards_arbitrary_params() {
364        let mut extra = BTreeMap::new();
365        extra.insert("custom_x".to_string(), "val".to_string());
366        let opts = EntitySubresourceOptions::builder().extra(extra).build();
367        let q = opts.to_query();
368        assert!(q.contains(&("custom_x".into(), "val".into())));
369    }
370
371    #[tokio::test]
372    async fn list_entity_contracts_empty_uei_returns_validation() {
373        let client = Client::builder().api_key("x").build().expect("build");
374        let err = client
375            .list_entity_contracts("", EntitySubresourceOptions::default())
376            .await
377            .expect_err("must error");
378        match err {
379            Error::Validation { message, .. } => assert!(message.contains("uei")),
380            other => panic!("expected Validation, got {other:?}"),
381        }
382    }
383
384    #[tokio::test]
385    async fn list_entity_subawards_empty_uei_returns_validation() {
386        let client = Client::builder().api_key("x").build().expect("build");
387        let err = client
388            .list_entity_subawards("", EntitySubresourceOptions::default())
389            .await
390            .expect_err("must error");
391        match err {
392            Error::Validation { message, .. } => assert!(message.contains("uei")),
393            other => panic!("expected Validation, got {other:?}"),
394        }
395    }
396
397    #[test]
398    fn budget_flows_options_emit_fiscal_year() {
399        let q = EntityBudgetFlowsOptions::builder()
400            .fiscal_year(2024u32)
401            .limit(10u32)
402            .build()
403            .to_query();
404        assert_eq!(
405            q,
406            vec![
407                ("limit".to_string(), "10".to_string()),
408                ("fiscal_year".to_string(), "2024".to_string())
409            ]
410        );
411    }
412
413    #[tokio::test]
414    async fn get_entity_budget_flows_empty_uei_returns_validation() {
415        let client = Client::builder().api_key("x").build().expect("build");
416        let err = client
417            .get_entity_budget_flows("", None)
418            .await
419            .expect_err("must error");
420        match err {
421            Error::Validation { message, .. } => assert!(message.contains("uei")),
422            other => panic!("expected Validation, got {other:?}"),
423        }
424    }
425
426    #[tokio::test]
427    async fn get_entity_metrics_empty_uei_returns_validation() {
428        let client = Client::builder().api_key("x").build().expect("build");
429        let err = client
430            .get_entity_metrics("", 12, "month")
431            .await
432            .expect_err("must error");
433        match err {
434            Error::Validation { message, .. } => assert!(message.contains("uei")),
435            other => panic!("expected Validation, got {other:?}"),
436        }
437    }
438
439    #[tokio::test]
440    async fn get_entity_metrics_zero_months_returns_validation() {
441        let client = Client::builder().api_key("x").build().expect("build");
442        let err = client
443            .get_entity_metrics("ABC123DEF456", 0, "month")
444            .await
445            .expect_err("must error");
446        assert!(matches!(err, Error::Validation { .. }));
447    }
448
449    #[tokio::test]
450    async fn get_entity_metrics_empty_period_grouping_returns_validation() {
451        let client = Client::builder().api_key("x").build().expect("build");
452        let err = client
453            .get_entity_metrics("ABC123DEF456", 12, "")
454            .await
455            .expect_err("must error");
456        assert!(matches!(err, Error::Validation { .. }));
457    }
458}