Skip to main content

tango/resources/
idv_subresources.rs

1//! IDV sub-resources: awards, child IDVs, transactions, summary, LCATs.
2//!
3//! Endpoints under `/api/idvs/{key}/…/` that share a common parameter shape.
4//! The Go SDK uses a mix of `ListIDVsOptions` (awards/child-idvs),
5//! `ListOptions` (transactions/summary-awards), and `EntityLcatsOptions`
6//! (lcats); the Rust port consolidates these behind a single
7//! [`IdvSubresourceOptions`] (pagination + shape + ordering + search + joiner)
8//! since the surface server-side params for these endpoints are identical at
9//! the SDK level.
10
11use crate::client::Client;
12use crate::error::{Error, Result};
13use crate::internal::{apply_pagination, push_opt};
14use crate::pagination::{FetchFn, Page, PageStream};
15use crate::resources::agencies::urlencoding;
16use crate::Record;
17use bon::Builder;
18use std::collections::BTreeMap;
19use std::sync::Arc;
20
21/// Options shared by every IDV sub-resource list endpoint
22/// (`/api/idvs/{key}/awards/`, `/child-idvs/`, `/transactions/`,
23/// `/summary/awards/`, `/lcats/`).
24///
25/// Carries pagination + shape + ordering + search + joiner. Use the `extra`
26/// field to forward filters not yet first-classed on this struct.
27#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
28#[non_exhaustive]
29pub struct IdvSubresourceOptions {
30    /// 1-based page number. Mutually exclusive with [`cursor`](Self::cursor).
31    #[builder(into)]
32    pub page: Option<u32>,
33    /// Page size.
34    #[builder(into)]
35    pub limit: Option<u32>,
36    /// Keyset cursor for cursor-paginated endpoints.
37    #[builder(into)]
38    pub cursor: Option<String>,
39    /// Comma-separated field selector.
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    /// Joiner for flattened keys (only sent when `flat=true`).
49    #[builder(into)]
50    pub joiner: Option<String>,
51    /// Server-side sort spec (prefix `-` for descending).
52    #[builder(into)]
53    pub ordering: Option<String>,
54    /// Free-text search filter.
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 IdvSubresourceOptions {
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/idvs/{key}/awards/` — task-order awards under a parent IDV.
92    pub async fn list_idv_awards(
93        &self,
94        key: &str,
95        opts: IdvSubresourceOptions,
96    ) -> Result<Page<Record>> {
97        list_idv_subresource(self, key, "awards", opts).await
98    }
99
100    /// Stream every task-order award under `key`.
101    pub fn iterate_idv_awards(&self, key: &str, opts: IdvSubresourceOptions) -> PageStream<Record> {
102        iterate_idv_subresource(self, key.to_string(), "awards", opts)
103    }
104
105    /// `GET /api/idvs/{key}/idvs/` — child IDVs nested under a parent IDV.
106    pub async fn list_idv_child_idvs(
107        &self,
108        key: &str,
109        opts: IdvSubresourceOptions,
110    ) -> Result<Page<Record>> {
111        list_idv_subresource(self, key, "idvs", opts).await
112    }
113
114    /// Stream every child IDV under `key`.
115    pub fn iterate_idv_child_idvs(
116        &self,
117        key: &str,
118        opts: IdvSubresourceOptions,
119    ) -> PageStream<Record> {
120        iterate_idv_subresource(self, key.to_string(), "idvs", opts)
121    }
122
123    /// `GET /api/idvs/{key}/transactions/` — raw transaction history backing
124    /// an IDV.
125    pub async fn list_idv_transactions(
126        &self,
127        key: &str,
128        opts: IdvSubresourceOptions,
129    ) -> Result<Page<Record>> {
130        list_idv_subresource(self, key, "transactions", opts).await
131    }
132
133    /// Stream every transaction backing `key`.
134    pub fn iterate_idv_transactions(
135        &self,
136        key: &str,
137        opts: IdvSubresourceOptions,
138    ) -> PageStream<Record> {
139        iterate_idv_subresource(self, key.to_string(), "transactions", opts)
140    }
141
142    /// `GET /api/idvs/{identifier}/summary/` — summary roll-up for an IDV.
143    ///
144    /// Deprecated: the v1.0.0 server returns 404 for this endpoint. Retained
145    /// for parity with the other SDKs; migrate to [`Client::get_idv`] with the
146    /// comprehensive shape.
147    #[deprecated(note = "Deprecated upstream; use get_idv with the comprehensive shape")]
148    pub async fn get_idv_summary(&self, key: &str) -> Result<Record> {
149        if key.is_empty() {
150            return Err(Error::Validation {
151                message: "get_idv_summary: key is required".into(),
152                response: None,
153            });
154        }
155        let path = format!("/api/idvs/{}/summary/", urlencoding(key));
156        self.get_json::<Record>(&path, &[]).await
157    }
158
159    /// `GET /api/idvs/{identifier}/summary/awards/` — awards belonging to an
160    /// IDV summary.
161    ///
162    /// Deprecated: the v1.0.0 server returns 404 for this endpoint. Retained
163    /// for parity with the other SDKs; migrate to [`Client::list_idv_awards`].
164    #[deprecated(note = "Deprecated upstream; use list_idv_awards")]
165    pub async fn list_idv_summary_awards(
166        &self,
167        key: &str,
168        opts: IdvSubresourceOptions,
169    ) -> Result<Page<Record>> {
170        list_idv_subresource(self, key, "summary/awards", opts).await
171    }
172
173    /// `GET /api/idvs/{key}/lcats/` — Labor Categories (LCATs) under an IDV.
174    pub async fn list_idv_lcats(
175        &self,
176        key: &str,
177        opts: IdvSubresourceOptions,
178    ) -> Result<Page<Record>> {
179        list_idv_subresource(self, key, "lcats", opts).await
180    }
181
182    /// Stream every LCAT under `key`.
183    pub fn iterate_idv_lcats(&self, key: &str, opts: IdvSubresourceOptions) -> PageStream<Record> {
184        iterate_idv_subresource(self, key.to_string(), "lcats", opts)
185    }
186}
187
188async fn list_idv_subresource(
189    client: &Client,
190    key: &str,
191    segment: &str,
192    opts: IdvSubresourceOptions,
193) -> Result<Page<Record>> {
194    if key.is_empty() {
195        return Err(Error::Validation {
196            message: "IDV sub-resource: key is required".into(),
197            response: None,
198        });
199    }
200    let q = opts.to_query();
201    let path = format!("/api/idvs/{}/{segment}/", urlencoding(key));
202    let bytes = client.get_bytes(&path, &q).await?;
203    Page::decode(&bytes)
204}
205
206fn iterate_idv_subresource(
207    client: &Client,
208    key: String,
209    segment: &'static str,
210    opts: IdvSubresourceOptions,
211) -> PageStream<Record> {
212    let opts = Arc::new(opts);
213    let key = Arc::new(key);
214    let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
215        let mut next = (*opts).clone();
216        next.page = page;
217        next.cursor = cursor;
218        let key = key.clone();
219        Box::pin(async move { list_idv_subresource(&client, &key, segment, next).await })
220    });
221    PageStream::new(client.clone(), fetch)
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
229        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
230    }
231
232    #[test]
233    fn options_emit_pagination_and_search() {
234        let opts = IdvSubresourceOptions::builder()
235            .limit(10u32)
236            .ordering("-award_date")
237            .search("software")
238            .build();
239        let q = opts.to_query();
240        assert_eq!(get_q(&q, "limit").as_deref(), Some("10"));
241        assert_eq!(get_q(&q, "ordering").as_deref(), Some("-award_date"));
242        assert_eq!(get_q(&q, "search").as_deref(), Some("software"));
243    }
244
245    #[test]
246    fn joiner_only_when_flat() {
247        let opts = IdvSubresourceOptions::builder()
248            .joiner("__".to_string())
249            .build();
250        let q = opts.to_query();
251        assert!(!q.iter().any(|(k, _)| k == "joiner"));
252
253        let opts = IdvSubresourceOptions::builder()
254            .flat(true)
255            .joiner("__".to_string())
256            .build();
257        let q = opts.to_query();
258        assert!(q.contains(&("joiner".into(), "__".into())));
259    }
260
261    #[test]
262    fn extra_forwards_arbitrary_params() {
263        let mut extra = BTreeMap::new();
264        extra.insert("region".to_string(), "west".to_string());
265        let opts = IdvSubresourceOptions::builder().extra(extra).build();
266        let q = opts.to_query();
267        assert!(q.contains(&("region".into(), "west".into())));
268    }
269
270    #[tokio::test]
271    async fn list_idv_awards_empty_key_returns_validation() {
272        let client = Client::builder().api_key("x").build().expect("build");
273        let err = client
274            .list_idv_awards("", IdvSubresourceOptions::default())
275            .await
276            .expect_err("must error");
277        match err {
278            Error::Validation { message, .. } => assert!(message.contains("key")),
279            other => panic!("expected Validation, got {other:?}"),
280        }
281    }
282
283    #[tokio::test]
284    async fn list_idv_lcats_empty_key_returns_validation() {
285        let client = Client::builder().api_key("x").build().expect("build");
286        let err = client
287            .list_idv_lcats("", IdvSubresourceOptions::default())
288            .await
289            .expect_err("must error");
290        match err {
291            Error::Validation { message, .. } => assert!(message.contains("key")),
292            other => panic!("expected Validation, got {other:?}"),
293        }
294    }
295
296    #[tokio::test]
297    #[allow(deprecated)]
298    async fn get_idv_summary_empty_key_returns_validation() {
299        let client = Client::builder().api_key("x").build().expect("build");
300        let err = client.get_idv_summary("").await.expect_err("must error");
301        match err {
302            Error::Validation { message, .. } => assert!(message.contains("key")),
303            other => panic!("expected Validation, got {other:?}"),
304        }
305    }
306}