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. Several UEIs joined by `|` match awards to any of them, up to 500 (Tango API 5.11.0).
113    #[builder(into)]
114    pub uei: Option<String>,
115    /// FPDS commercial item acquisition procedures code (Tango API 5.10.0), case-insensitive: `A` (commercial products/services), `B` (products or services pursuant to FAR 12.102(f)), `C` (services pursuant to FAR 12.102(g)), `D` (commercial procedures not used) or `E` (DoD Section 803 Commercial Solutions Opening). OR several with `|`, e.g. `A|E`; an unknown or multi-character code matches nothing. The API does not offer it on OTAs or OT IDVs.
116    #[builder(into)]
117    pub commercial_item_acquisition_procedures: Option<String>,
118
119    /// Filter by Tango award key (the detail-endpoint identifier). Supports multi-value OR via `|`.
120    #[builder(into)]
121    pub key: Option<String>,
122
123    /// Escape hatch for filter keys not yet first-classed on this struct.
124    #[builder(default)]
125    pub extra: BTreeMap<String, String>,
126}
127
128impl ListIDVsOptions {
129    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
130        let mut q = Vec::new();
131        apply_pagination(
132            &mut q,
133            self.page,
134            self.limit,
135            self.cursor.as_deref(),
136            self.shape.as_deref(),
137            self.flat,
138            self.flat_lists,
139        );
140
141        push_opt(&mut q, "award_date", self.award_date.as_deref());
142        push_opt(&mut q, "award_date_gte", self.award_date_gte.as_deref());
143        push_opt(&mut q, "award_date_lte", self.award_date_lte.as_deref());
144        push_opt(&mut q, "awarding_agency", self.awarding_agency.as_deref());
145        push_opt(&mut q, "funding_agency", self.funding_agency.as_deref());
146        push_opt(&mut q, "expiring_gte", self.expiring_gte.as_deref());
147        push_opt(&mut q, "expiring_lte", self.expiring_lte.as_deref());
148        push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
149        push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
150        push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
151        push_opt(&mut q, "idv_type", self.idv_type.as_deref());
152        push_opt(
153            &mut q,
154            "last_date_to_order_gte",
155            self.last_date_to_order_gte.as_deref(),
156        );
157        push_opt(
158            &mut q,
159            "last_date_to_order_lte",
160            self.last_date_to_order_lte.as_deref(),
161        );
162        push_opt(&mut q, "naics", self.naics.as_deref());
163        push_opt(&mut q, "ordering", self.ordering.as_deref());
164        push_opt(&mut q, "piid", self.piid.as_deref());
165        push_opt(&mut q, "key", self.key.as_deref());
166        push_opt(
167            &mut q,
168            "pop_start_date_gte",
169            self.pop_start_date_gte.as_deref(),
170        );
171        push_opt(
172            &mut q,
173            "pop_start_date_lte",
174            self.pop_start_date_lte.as_deref(),
175        );
176        push_opt(&mut q, "psc", self.psc.as_deref());
177        push_opt(&mut q, "recipient", self.recipient.as_deref());
178        push_opt(&mut q, "search", self.search.as_deref());
179        push_opt(&mut q, "set_aside", self.set_aside.as_deref());
180        push_opt(
181            &mut q,
182            "solicitation_identifier",
183            self.solicitation_identifier.as_deref(),
184        );
185        push_opt(&mut q, "uei", self.uei.as_deref());
186        push_opt(
187            &mut q,
188            "commercial_item_acquisition_procedures",
189            self.commercial_item_acquisition_procedures.as_deref(),
190        );
191
192        for (k, v) in &self.extra {
193            if !v.is_empty() {
194                q.push((k.clone(), v.clone()));
195            }
196        }
197        q
198    }
199}
200
201/// Options for [`Client::get_idv`].
202#[allow(clippy::upper_case_acronyms)]
203#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
204#[non_exhaustive]
205pub struct GetIDVOptions {
206    /// Shape selector. When empty, the server returns its comprehensive default.
207    #[builder(into)]
208    pub shape: Option<String>,
209    /// Flatten nested objects into dot-separated keys.
210    #[builder(default)]
211    pub flat: bool,
212    /// When `flat=true`, also flatten list-valued nested fields.
213    #[builder(default)]
214    pub flat_lists: bool,
215}
216
217impl GetIDVOptions {
218    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
219        let mut q = Vec::new();
220        push_opt(&mut q, "shape", self.shape.as_deref());
221        if self.flat {
222            q.push(("flat".into(), "true".into()));
223        }
224        if self.flat_lists {
225            q.push(("flat_lists".into(), "true".into()));
226        }
227        q
228    }
229}
230
231impl Client {
232    /// `GET /api/idvs/` — one page of Indefinite Delivery Vehicle records.
233    pub async fn list_idvs(&self, opts: ListIDVsOptions) -> Result<Page<Record>> {
234        let q = opts.to_query();
235        self.get_page("/api/idvs/", &q).await
236    }
237
238    /// `GET /api/idvs/{key}/` — fetch a single IDV by key.
239    ///
240    /// Returns a free-form [`Record`]; deserialize into your own struct via
241    /// `serde_json::from_value(Value::Object(record))` if you need typed fields.
242    pub async fn get_idv(&self, key: &str, opts: Option<GetIDVOptions>) -> Result<Record> {
243        if key.is_empty() {
244            return Err(Error::Validation {
245                message: "get_idv: key is required".into(),
246                response: None,
247            });
248        }
249        let q = opts.unwrap_or_default().to_query();
250        let path = format!("/api/idvs/{}/", urlencoding(key));
251        self.get_json::<Record>(&path, &q).await
252    }
253
254    /// Stream every IDV record matching `opts`. The stream follows `?cursor=`
255    /// (or `?page=` fallback) on the server's `next` URL.
256    pub fn iterate_idvs(&self, opts: ListIDVsOptions) -> PageStream<Record> {
257        let opts = Arc::new(opts);
258        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
259            let mut next = (*opts).clone();
260            next.page = page;
261            next.cursor = cursor;
262            Box::pin(async move { client.list_idvs(next).await })
263        });
264        PageStream::new(self.clone(), fetch)
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
273        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
274    }
275
276    #[test]
277    fn list_idvs_all_filters_emit() {
278        let opts = ListIDVsOptions::builder()
279            .award_date("2024-01-01")
280            .awarding_agency("9700")
281            .funding_agency("9800")
282            .idv_type("A")
283            .naics("541512")
284            .piid("W15P7T19D0001")
285            .psc("D302")
286            .recipient("Acme")
287            .search("keyword")
288            .set_aside("8A")
289            .solicitation_identifier("SOL001")
290            .uei("UEI12345")
291            .ordering("-award_date")
292            .build();
293        let q = opts.to_query();
294        assert_eq!(get_q(&q, "award_date").as_deref(), Some("2024-01-01"));
295        assert_eq!(get_q(&q, "awarding_agency").as_deref(), Some("9700"));
296        assert_eq!(get_q(&q, "funding_agency").as_deref(), Some("9800"));
297        assert_eq!(get_q(&q, "idv_type").as_deref(), Some("A"));
298        assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
299        assert_eq!(get_q(&q, "piid").as_deref(), Some("W15P7T19D0001"));
300        assert_eq!(get_q(&q, "psc").as_deref(), Some("D302"));
301        assert_eq!(get_q(&q, "recipient").as_deref(), Some("Acme"));
302        assert_eq!(get_q(&q, "search").as_deref(), Some("keyword"));
303        assert_eq!(get_q(&q, "set_aside").as_deref(), Some("8A"));
304        assert_eq!(
305            get_q(&q, "solicitation_identifier").as_deref(),
306            Some("SOL001")
307        );
308        assert_eq!(get_q(&q, "uei").as_deref(), Some("UEI12345"));
309        assert_eq!(get_q(&q, "ordering").as_deref(), Some("-award_date"));
310    }
311
312    #[test]
313    fn list_idvs_zero_value_omitted() {
314        let opts = ListIDVsOptions::builder().build();
315        let q = opts.to_query();
316        assert!(q.is_empty(), "expected empty query, got {q:?}");
317    }
318
319    #[test]
320    fn list_idvs_extra_emits() {
321        let mut extra = BTreeMap::new();
322        extra.insert("custom_x".to_string(), "xval".to_string());
323        let opts = ListIDVsOptions::builder().extra(extra).build();
324        let q = opts.to_query();
325        assert!(q.contains(&("custom_x".into(), "xval".into())));
326    }
327
328    #[test]
329    fn list_idvs_cursor_wins_over_page() {
330        let opts = ListIDVsOptions::builder()
331            .page(3u32)
332            .cursor("abc".to_string())
333            .build();
334        let q = opts.to_query();
335        assert_eq!(get_q(&q, "cursor").as_deref(), Some("abc"));
336        assert_eq!(get_q(&q, "page"), None);
337    }
338
339    #[test]
340    fn get_idv_opts_emits_shape_and_flat() {
341        let opts = GetIDVOptions::builder()
342            .shape("idvs(minimal)")
343            .flat(true)
344            .flat_lists(true)
345            .build();
346        let q = opts.to_query();
347        assert!(q.contains(&("shape".into(), "idvs(minimal)".into())));
348        assert!(q.contains(&("flat".into(), "true".into())));
349        assert!(q.contains(&("flat_lists".into(), "true".into())));
350    }
351
352    #[tokio::test]
353    async fn get_idv_empty_key_returns_validation() {
354        let client = Client::builder().api_key("x").build().expect("build");
355        let err = client.get_idv("", None).await.expect_err("must error");
356        match err {
357            Error::Validation { message, .. } => {
358                assert!(message.contains("key"));
359            }
360            other => panic!("expected Validation, got {other:?}"),
361        }
362    }
363
364    #[test]
365    fn list_idvs_key_filter_emits() {
366        let q = ListIDVsOptions::builder().key("K1").build().to_query();
367        assert_eq!(get_q(&q, "key").as_deref(), Some("K1"));
368    }
369
370    #[test]
371    fn list_idvs_commercial_item_acquisition_procedures_emits() {
372        let q = ListIDVsOptions::builder()
373            .commercial_item_acquisition_procedures("A|E")
374            .build()
375            .to_query();
376        assert_eq!(
377            get_q(&q, "commercial_item_acquisition_procedures").as_deref(),
378            Some("A|E")
379        );
380    }
381}