Skip to main content

tango/resources/
idvs.rs

1//! `GET /api/idvs/` — list and stream Indefinite Delivery Vehicle records.
2
3use crate::client::Client;
4use crate::error::{Error, Result};
5use crate::internal::{apply_pagination, push_opt};
6use crate::pagination::{FetchFn, Page, PageStream};
7use crate::resources::agencies::urlencoding;
8use crate::Record;
9use bon::Builder;
10use std::collections::BTreeMap;
11use std::sync::Arc;
12
13/// Options for [`Client::list_idvs`] and [`Client::iterate_idvs`].
14///
15/// Mirrors the Go SDK's `ListIDVsOptions`. All filters are sent as query
16/// parameters; empty/None values are omitted.
17#[allow(clippy::upper_case_acronyms)]
18#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
19#[non_exhaustive]
20pub struct ListIDVsOptions {
21    // ----- Pagination + shape (shared across every list endpoint) -----
22    /// 1-based page number. Mutually exclusive with [`cursor`](Self::cursor).
23    #[builder(into)]
24    pub page: Option<u32>,
25    /// Page size (server caps at 100 on most endpoints).
26    #[builder(into)]
27    pub limit: Option<u32>,
28    /// Keyset cursor for cursor-paginated endpoints. Pass the `cursor` field
29    /// from the previous [`Page`](crate::Page).
30    #[builder(into)]
31    pub cursor: Option<String>,
32    /// Comma-separated field selector. Use a `SHAPE_*` constant or roll your own.
33    #[builder(into)]
34    pub shape: Option<String>,
35    /// Collapse nested objects into dot-separated keys.
36    #[builder(default)]
37    pub flat: bool,
38    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
39    #[builder(default)]
40    pub flat_lists: bool,
41
42    // ----- Resource filters -----
43    /// Single-day `award_date` filter (ISO `YYYY-MM-DD`).
44    #[builder(into)]
45    pub award_date: Option<String>,
46    /// Lower bound for `award_date` (inclusive).
47    #[builder(into)]
48    pub award_date_gte: Option<String>,
49    /// Upper bound for `award_date` (inclusive).
50    #[builder(into)]
51    pub award_date_lte: Option<String>,
52    /// Awarding agency CGAC code (e.g. `"9700"`).
53    #[builder(into)]
54    pub awarding_agency: Option<String>,
55    /// Funding agency CGAC code.
56    #[builder(into)]
57    pub funding_agency: Option<String>,
58    /// Lower bound for the IDV's expiration date.
59    #[builder(into)]
60    pub expiring_gte: Option<String>,
61    /// Upper bound for the IDV's expiration date.
62    #[builder(into)]
63    pub expiring_lte: Option<String>,
64    /// `fiscal_year` filter.
65    #[builder(into)]
66    pub fiscal_year: Option<String>,
67    /// Lower bound for `fiscal_year`.
68    #[builder(into)]
69    pub fiscal_year_gte: Option<String>,
70    /// Upper bound for `fiscal_year`.
71    #[builder(into)]
72    pub fiscal_year_lte: Option<String>,
73    /// IDV type code (e.g. `"A"`).
74    #[builder(into)]
75    pub idv_type: Option<String>,
76    /// Lower bound for the IDV's last date to order.
77    #[builder(into)]
78    pub last_date_to_order_gte: Option<String>,
79    /// Upper bound for the IDV's last date to order.
80    #[builder(into)]
81    pub last_date_to_order_lte: Option<String>,
82    /// NAICS code.
83    #[builder(into)]
84    pub naics: Option<String>,
85    /// Server-side sort spec (prefix `-` for descending).
86    #[builder(into)]
87    pub ordering: Option<String>,
88    /// Procurement Instrument Identifier filter.
89    #[builder(into)]
90    pub piid: Option<String>,
91    /// Lower bound for period-of-performance start date.
92    #[builder(into)]
93    pub pop_start_date_gte: Option<String>,
94    /// Upper bound for period-of-performance start date.
95    #[builder(into)]
96    pub pop_start_date_lte: Option<String>,
97    /// PSC code.
98    #[builder(into)]
99    pub psc: Option<String>,
100    /// Recipient name filter.
101    #[builder(into)]
102    pub recipient: Option<String>,
103    /// Free-text search filter.
104    #[builder(into)]
105    pub search: Option<String>,
106    /// Set-aside filter.
107    #[builder(into)]
108    pub set_aside: Option<String>,
109    /// Solicitation identifier filter.
110    #[builder(into)]
111    pub solicitation_identifier: Option<String>,
112    /// Recipient UEI filter.
113    #[builder(into)]
114    pub uei: Option<String>,
115
116    /// Filter by Tango award key (the detail-endpoint identifier). Supports multi-value OR via `|`.
117    #[builder(into)]
118    pub key: Option<String>,
119
120    /// Escape hatch for filter keys not yet first-classed on this struct.
121    #[builder(default)]
122    pub extra: BTreeMap<String, String>,
123}
124
125impl ListIDVsOptions {
126    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
127        let mut q = Vec::new();
128        apply_pagination(
129            &mut q,
130            self.page,
131            self.limit,
132            self.cursor.as_deref(),
133            self.shape.as_deref(),
134            self.flat,
135            self.flat_lists,
136        );
137
138        push_opt(&mut q, "award_date", self.award_date.as_deref());
139        push_opt(&mut q, "award_date_gte", self.award_date_gte.as_deref());
140        push_opt(&mut q, "award_date_lte", self.award_date_lte.as_deref());
141        push_opt(&mut q, "awarding_agency", self.awarding_agency.as_deref());
142        push_opt(&mut q, "funding_agency", self.funding_agency.as_deref());
143        push_opt(&mut q, "expiring_gte", self.expiring_gte.as_deref());
144        push_opt(&mut q, "expiring_lte", self.expiring_lte.as_deref());
145        push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
146        push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
147        push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
148        push_opt(&mut q, "idv_type", self.idv_type.as_deref());
149        push_opt(
150            &mut q,
151            "last_date_to_order_gte",
152            self.last_date_to_order_gte.as_deref(),
153        );
154        push_opt(
155            &mut q,
156            "last_date_to_order_lte",
157            self.last_date_to_order_lte.as_deref(),
158        );
159        push_opt(&mut q, "naics", self.naics.as_deref());
160        push_opt(&mut q, "ordering", self.ordering.as_deref());
161        push_opt(&mut q, "piid", self.piid.as_deref());
162        push_opt(&mut q, "key", self.key.as_deref());
163        push_opt(
164            &mut q,
165            "pop_start_date_gte",
166            self.pop_start_date_gte.as_deref(),
167        );
168        push_opt(
169            &mut q,
170            "pop_start_date_lte",
171            self.pop_start_date_lte.as_deref(),
172        );
173        push_opt(&mut q, "psc", self.psc.as_deref());
174        push_opt(&mut q, "recipient", self.recipient.as_deref());
175        push_opt(&mut q, "search", self.search.as_deref());
176        push_opt(&mut q, "set_aside", self.set_aside.as_deref());
177        push_opt(
178            &mut q,
179            "solicitation_identifier",
180            self.solicitation_identifier.as_deref(),
181        );
182        push_opt(&mut q, "uei", self.uei.as_deref());
183
184        for (k, v) in &self.extra {
185            if !v.is_empty() {
186                q.push((k.clone(), v.clone()));
187            }
188        }
189        q
190    }
191}
192
193/// Options for [`Client::get_idv`].
194#[allow(clippy::upper_case_acronyms)]
195#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
196#[non_exhaustive]
197pub struct GetIDVOptions {
198    /// Shape selector. When empty, the server returns its comprehensive default.
199    #[builder(into)]
200    pub shape: Option<String>,
201    /// Flatten nested objects into dot-separated keys.
202    #[builder(default)]
203    pub flat: bool,
204    /// When `flat=true`, also flatten list-valued nested fields.
205    #[builder(default)]
206    pub flat_lists: bool,
207}
208
209impl GetIDVOptions {
210    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
211        let mut q = Vec::new();
212        push_opt(&mut q, "shape", self.shape.as_deref());
213        if self.flat {
214            q.push(("flat".into(), "true".into()));
215        }
216        if self.flat_lists {
217            q.push(("flat_lists".into(), "true".into()));
218        }
219        q
220    }
221}
222
223impl Client {
224    /// `GET /api/idvs/` — one page of Indefinite Delivery Vehicle records.
225    pub async fn list_idvs(&self, opts: ListIDVsOptions) -> Result<Page<Record>> {
226        let q = opts.to_query();
227        let bytes = self.get_bytes("/api/idvs/", &q).await?;
228        Page::decode(&bytes)
229    }
230
231    /// `GET /api/idvs/{key}/` — fetch a single IDV by key.
232    ///
233    /// Returns a free-form [`Record`]; deserialize into your own struct via
234    /// `serde_json::from_value(Value::Object(record))` if you need typed fields.
235    pub async fn get_idv(&self, key: &str, opts: Option<GetIDVOptions>) -> Result<Record> {
236        if key.is_empty() {
237            return Err(Error::Validation {
238                message: "get_idv: key is required".into(),
239                response: None,
240            });
241        }
242        let q = opts.unwrap_or_default().to_query();
243        let path = format!("/api/idvs/{}/", urlencoding(key));
244        self.get_json::<Record>(&path, &q).await
245    }
246
247    /// Stream every IDV record matching `opts`. The stream follows `?cursor=`
248    /// (or `?page=` fallback) on the server's `next` URL.
249    pub fn iterate_idvs(&self, opts: ListIDVsOptions) -> PageStream<Record> {
250        let opts = Arc::new(opts);
251        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
252            let mut next = (*opts).clone();
253            next.page = page;
254            next.cursor = cursor;
255            Box::pin(async move { client.list_idvs(next).await })
256        });
257        PageStream::new(self.clone(), fetch)
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
266        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
267    }
268
269    #[test]
270    fn list_idvs_all_filters_emit() {
271        let opts = ListIDVsOptions::builder()
272            .award_date("2024-01-01")
273            .awarding_agency("9700")
274            .funding_agency("9800")
275            .idv_type("A")
276            .naics("541512")
277            .piid("W15P7T19D0001")
278            .psc("D302")
279            .recipient("Acme")
280            .search("keyword")
281            .set_aside("8A")
282            .solicitation_identifier("SOL001")
283            .uei("UEI12345")
284            .ordering("-award_date")
285            .build();
286        let q = opts.to_query();
287        assert_eq!(get_q(&q, "award_date").as_deref(), Some("2024-01-01"));
288        assert_eq!(get_q(&q, "awarding_agency").as_deref(), Some("9700"));
289        assert_eq!(get_q(&q, "funding_agency").as_deref(), Some("9800"));
290        assert_eq!(get_q(&q, "idv_type").as_deref(), Some("A"));
291        assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
292        assert_eq!(get_q(&q, "piid").as_deref(), Some("W15P7T19D0001"));
293        assert_eq!(get_q(&q, "psc").as_deref(), Some("D302"));
294        assert_eq!(get_q(&q, "recipient").as_deref(), Some("Acme"));
295        assert_eq!(get_q(&q, "search").as_deref(), Some("keyword"));
296        assert_eq!(get_q(&q, "set_aside").as_deref(), Some("8A"));
297        assert_eq!(
298            get_q(&q, "solicitation_identifier").as_deref(),
299            Some("SOL001")
300        );
301        assert_eq!(get_q(&q, "uei").as_deref(), Some("UEI12345"));
302        assert_eq!(get_q(&q, "ordering").as_deref(), Some("-award_date"));
303    }
304
305    #[test]
306    fn list_idvs_zero_value_omitted() {
307        let opts = ListIDVsOptions::builder().build();
308        let q = opts.to_query();
309        assert!(q.is_empty(), "expected empty query, got {q:?}");
310    }
311
312    #[test]
313    fn list_idvs_extra_emits() {
314        let mut extra = BTreeMap::new();
315        extra.insert("custom_x".to_string(), "xval".to_string());
316        let opts = ListIDVsOptions::builder().extra(extra).build();
317        let q = opts.to_query();
318        assert!(q.contains(&("custom_x".into(), "xval".into())));
319    }
320
321    #[test]
322    fn list_idvs_cursor_wins_over_page() {
323        let opts = ListIDVsOptions::builder()
324            .page(3u32)
325            .cursor("abc".to_string())
326            .build();
327        let q = opts.to_query();
328        assert_eq!(get_q(&q, "cursor").as_deref(), Some("abc"));
329        assert_eq!(get_q(&q, "page"), None);
330    }
331
332    #[test]
333    fn get_idv_opts_emits_shape_and_flat() {
334        let opts = GetIDVOptions::builder()
335            .shape("idvs(minimal)")
336            .flat(true)
337            .flat_lists(true)
338            .build();
339        let q = opts.to_query();
340        assert!(q.contains(&("shape".into(), "idvs(minimal)".into())));
341        assert!(q.contains(&("flat".into(), "true".into())));
342        assert!(q.contains(&("flat_lists".into(), "true".into())));
343    }
344
345    #[tokio::test]
346    async fn get_idv_empty_key_returns_validation() {
347        let client = Client::builder().api_key("x").build().expect("build");
348        let err = client.get_idv("", None).await.expect_err("must error");
349        match err {
350            Error::Validation { message, .. } => {
351                assert!(message.contains("key"));
352            }
353            other => panic!("expected Validation, got {other:?}"),
354        }
355    }
356
357    #[test]
358    fn list_idvs_key_filter_emits() {
359        let q = ListIDVsOptions::builder().key("K1").build().to_query();
360        assert_eq!(get_q(&q, "key").as_deref(), Some("K1"));
361    }
362}