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 shared by every entity sub-resource list endpoint
23/// (`/api/entities/{uei}/contracts/`, `/idvs/`, `/otas/`, `/otidvs/`,
24/// `/subawards/`, `/lcats/`).
25#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
26#[non_exhaustive]
27pub struct EntitySubresourceOptions {
28    /// 1-based page number. Mutually exclusive with [`cursor`](Self::cursor).
29    #[builder(into)]
30    pub page: Option<u32>,
31    /// Page size.
32    #[builder(into)]
33    pub limit: Option<u32>,
34    /// Keyset cursor for cursor-paginated endpoints.
35    #[builder(into)]
36    pub cursor: Option<String>,
37    /// Comma-separated field selector.
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    /// Joiner for flattened keys (only sent when `flat=true`).
47    #[builder(into)]
48    pub joiner: Option<String>,
49    /// Server-side sort spec. Endpoint-specific allowlist; prefix `-` for
50    /// descending. The subawards endpoint enforces a stricter allowlist than
51    /// most — pass values verbatim and let the server validate.
52    #[builder(into)]
53    pub ordering: Option<String>,
54    /// Free-text search filter where supported by the endpoint.
55    #[builder(into)]
56    pub search: Option<String>,
57    /// Escape hatch for filter keys not yet first-classed.
58    #[builder(default)]
59    pub extra: BTreeMap<String, String>,
60}
61
62impl EntitySubresourceOptions {
63    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
64        let mut q = Vec::new();
65        apply_pagination(
66            &mut q,
67            self.page,
68            self.limit,
69            self.cursor.as_deref(),
70            self.shape.as_deref(),
71            self.flat,
72            self.flat_lists,
73        );
74        if self.flat {
75            if let Some(j) = self.joiner.as_deref().filter(|s| !s.is_empty()) {
76                q.push(("joiner".into(), j.into()));
77            }
78        }
79        push_opt(&mut q, "ordering", self.ordering.as_deref());
80        push_opt(&mut q, "search", self.search.as_deref());
81        for (k, v) in &self.extra {
82            if !v.is_empty() {
83                q.push((k.clone(), v.clone()));
84            }
85        }
86        q
87    }
88}
89
90impl Client {
91    /// `GET /api/entities/{uei}/contracts/` — contracts awarded to this entity.
92    pub async fn list_entity_contracts(
93        &self,
94        uei: &str,
95        opts: EntitySubresourceOptions,
96    ) -> Result<Page<Record>> {
97        list_entity_subresource(self, uei, "contracts", opts).await
98    }
99
100    /// Stream every contract awarded to `uei`.
101    pub fn iterate_entity_contracts(
102        &self,
103        uei: &str,
104        opts: EntitySubresourceOptions,
105    ) -> PageStream<Record> {
106        iterate_entity_subresource(self, uei.to_string(), "contracts", opts)
107    }
108
109    /// `GET /api/entities/{uei}/idvs/` — IDVs held by this entity.
110    pub async fn list_entity_idvs(
111        &self,
112        uei: &str,
113        opts: EntitySubresourceOptions,
114    ) -> Result<Page<Record>> {
115        list_entity_subresource(self, uei, "idvs", opts).await
116    }
117
118    /// Stream every IDV held by `uei`.
119    pub fn iterate_entity_idvs(
120        &self,
121        uei: &str,
122        opts: EntitySubresourceOptions,
123    ) -> PageStream<Record> {
124        iterate_entity_subresource(self, uei.to_string(), "idvs", opts)
125    }
126
127    /// `GET /api/entities/{uei}/otas/` — Other Transaction Awards held by this
128    /// entity.
129    pub async fn list_entity_otas(
130        &self,
131        uei: &str,
132        opts: EntitySubresourceOptions,
133    ) -> Result<Page<Record>> {
134        list_entity_subresource(self, uei, "otas", opts).await
135    }
136
137    /// Stream every OTA held by `uei`.
138    pub fn iterate_entity_otas(
139        &self,
140        uei: &str,
141        opts: EntitySubresourceOptions,
142    ) -> PageStream<Record> {
143        iterate_entity_subresource(self, uei.to_string(), "otas", opts)
144    }
145
146    /// `GET /api/entities/{uei}/otidvs/` — Other Transaction IDVs held by this
147    /// entity.
148    pub async fn list_entity_otidvs(
149        &self,
150        uei: &str,
151        opts: EntitySubresourceOptions,
152    ) -> Result<Page<Record>> {
153        list_entity_subresource(self, uei, "otidvs", opts).await
154    }
155
156    /// Stream every OTIDV held by `uei`.
157    pub fn iterate_entity_otidvs(
158        &self,
159        uei: &str,
160        opts: EntitySubresourceOptions,
161    ) -> PageStream<Record> {
162        iterate_entity_subresource(self, uei.to_string(), "otidvs", opts)
163    }
164
165    /// `GET /api/entities/{uei}/subawards/` — subawards reported against
166    /// contracts held by this entity.
167    pub async fn list_entity_subawards(
168        &self,
169        uei: &str,
170        opts: EntitySubresourceOptions,
171    ) -> Result<Page<Record>> {
172        list_entity_subresource(self, uei, "subawards", opts).await
173    }
174
175    /// Stream every subaward reported under `uei`.
176    pub fn iterate_entity_subawards(
177        &self,
178        uei: &str,
179        opts: EntitySubresourceOptions,
180    ) -> PageStream<Record> {
181        iterate_entity_subresource(self, uei.to_string(), "subawards", opts)
182    }
183
184    /// `GET /api/entities/{uei}/lcats/` — Labor Categories advertised by this
185    /// entity.
186    pub async fn list_entity_lcats(
187        &self,
188        uei: &str,
189        opts: EntitySubresourceOptions,
190    ) -> Result<Page<Record>> {
191        list_entity_subresource(self, uei, "lcats", opts).await
192    }
193
194    /// Stream every LCAT advertised by `uei`.
195    pub fn iterate_entity_lcats(
196        &self,
197        uei: &str,
198        opts: EntitySubresourceOptions,
199    ) -> PageStream<Record> {
200        iterate_entity_subresource(self, uei.to_string(), "lcats", opts)
201    }
202
203    /// `GET /api/entities/{uei}/metrics/{months}/{period_grouping}/` — rolling
204    /// windowed metrics for this entity. Mirrors the signature of the sibling
205    /// SDKs (Node / Python / Go).
206    ///
207    /// `months` is the rolling-window length (must be > 0). `period_grouping`
208    /// is typically `"month"` or `"quarter"`.
209    pub async fn get_entity_metrics(
210        &self,
211        uei: &str,
212        months: u32,
213        period_grouping: &str,
214    ) -> Result<Record> {
215        if uei.is_empty() {
216            return Err(Error::Validation {
217                message: "get_entity_metrics: uei is required".into(),
218                response: None,
219            });
220        }
221        if months == 0 {
222            return Err(Error::Validation {
223                message: "get_entity_metrics: months must be > 0".into(),
224                response: None,
225            });
226        }
227        if period_grouping.is_empty() {
228            return Err(Error::Validation {
229                message: "get_entity_metrics: period_grouping is required".into(),
230                response: None,
231            });
232        }
233        let path = format!(
234            "/api/entities/{}/metrics/{}/{}/",
235            urlencoding(uei),
236            months,
237            urlencoding(period_grouping),
238        );
239        self.get_json::<Record>(&path, &[]).await
240    }
241}
242
243async fn list_entity_subresource(
244    client: &Client,
245    uei: &str,
246    segment: &str,
247    opts: EntitySubresourceOptions,
248) -> Result<Page<Record>> {
249    if uei.is_empty() {
250        return Err(Error::Validation {
251            message: "entity sub-resource: uei is required".into(),
252            response: None,
253        });
254    }
255    let q = opts.to_query();
256    let path = format!("/api/entities/{}/{segment}/", urlencoding(uei));
257    let bytes = client.get_bytes(&path, &q).await?;
258    Page::decode(&bytes)
259}
260
261fn iterate_entity_subresource(
262    client: &Client,
263    uei: String,
264    segment: &'static str,
265    opts: EntitySubresourceOptions,
266) -> PageStream<Record> {
267    let opts = Arc::new(opts);
268    let uei = Arc::new(uei);
269    let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
270        let mut next = (*opts).clone();
271        next.page = page;
272        next.cursor = cursor;
273        let uei = uei.clone();
274        Box::pin(async move { list_entity_subresource(&client, &uei, segment, next).await })
275    });
276    PageStream::new(client.clone(), fetch)
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
284        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
285    }
286
287    #[test]
288    fn options_emit_pagination_shape_and_search() {
289        let opts = EntitySubresourceOptions::builder()
290            .limit(10u32)
291            .shape("contracts(minimal)")
292            .ordering("-award_date")
293            .search("software")
294            .build();
295        let q = opts.to_query();
296        assert_eq!(get_q(&q, "limit").as_deref(), Some("10"));
297        assert_eq!(get_q(&q, "shape").as_deref(), Some("contracts(minimal)"));
298        assert_eq!(get_q(&q, "ordering").as_deref(), Some("-award_date"));
299        assert_eq!(get_q(&q, "search").as_deref(), Some("software"));
300        assert!(!q.iter().any(|(k, _)| k == "joiner"));
301    }
302
303    #[test]
304    fn joiner_only_when_flat() {
305        let opts = EntitySubresourceOptions::builder()
306            .joiner("__".to_string())
307            .build();
308        let q = opts.to_query();
309        assert!(!q.iter().any(|(k, _)| k == "joiner"));
310
311        let opts = EntitySubresourceOptions::builder()
312            .flat(true)
313            .joiner("__".to_string())
314            .build();
315        let q = opts.to_query();
316        assert!(q.contains(&("joiner".into(), "__".into())));
317    }
318
319    #[test]
320    fn extra_forwards_arbitrary_params() {
321        let mut extra = BTreeMap::new();
322        extra.insert("custom_x".to_string(), "val".to_string());
323        let opts = EntitySubresourceOptions::builder().extra(extra).build();
324        let q = opts.to_query();
325        assert!(q.contains(&("custom_x".into(), "val".into())));
326    }
327
328    #[tokio::test]
329    async fn list_entity_contracts_empty_uei_returns_validation() {
330        let client = Client::builder().api_key("x").build().expect("build");
331        let err = client
332            .list_entity_contracts("", EntitySubresourceOptions::default())
333            .await
334            .expect_err("must error");
335        match err {
336            Error::Validation { message, .. } => assert!(message.contains("uei")),
337            other => panic!("expected Validation, got {other:?}"),
338        }
339    }
340
341    #[tokio::test]
342    async fn list_entity_subawards_empty_uei_returns_validation() {
343        let client = Client::builder().api_key("x").build().expect("build");
344        let err = client
345            .list_entity_subawards("", EntitySubresourceOptions::default())
346            .await
347            .expect_err("must error");
348        match err {
349            Error::Validation { message, .. } => assert!(message.contains("uei")),
350            other => panic!("expected Validation, got {other:?}"),
351        }
352    }
353
354    #[tokio::test]
355    async fn get_entity_metrics_empty_uei_returns_validation() {
356        let client = Client::builder().api_key("x").build().expect("build");
357        let err = client
358            .get_entity_metrics("", 12, "month")
359            .await
360            .expect_err("must error");
361        match err {
362            Error::Validation { message, .. } => assert!(message.contains("uei")),
363            other => panic!("expected Validation, got {other:?}"),
364        }
365    }
366
367    #[tokio::test]
368    async fn get_entity_metrics_zero_months_returns_validation() {
369        let client = Client::builder().api_key("x").build().expect("build");
370        let err = client
371            .get_entity_metrics("ABC123DEF456", 0, "month")
372            .await
373            .expect_err("must error");
374        assert!(matches!(err, Error::Validation { .. }));
375    }
376
377    #[tokio::test]
378    async fn get_entity_metrics_empty_period_grouping_returns_validation() {
379        let client = Client::builder().api_key("x").build().expect("build");
380        let err = client
381            .get_entity_metrics("ABC123DEF456", 12, "")
382            .await
383            .expect_err("must error");
384        assert!(matches!(err, Error::Validation { .. }));
385    }
386}