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