Skip to main content

tango/resources/
vehicles.rs

1//! `GET /api/vehicles/` and `GET /api/vehicles/{uuid}/` — list, stream,
2//! and fetch federal contracting vehicles (GWACs, BPAs, IDIQs, etc.).
3
4use crate::client::Client;
5use crate::error::{Error, Result};
6use crate::internal::{apply_pagination, push_opt, push_opt_u32};
7use crate::pagination::{FetchFn, Page, PageStream};
8use crate::resources::agencies::urlencoding;
9use crate::Record;
10use bon::Builder;
11use std::collections::BTreeMap;
12use std::sync::Arc;
13
14/// Options for [`Client::list_vehicles`] and [`Client::iterate_vehicles`].
15///
16/// Mirrors `ListVehiclesOptions` in the Go SDK. Server enforces a strict
17/// ordering allowlist; passing an unrecognised value will 400.
18#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
19#[non_exhaustive]
20pub struct ListVehiclesOptions {
21    // ----- Pagination + shape -----
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.
29    #[builder(into)]
30    pub cursor: Option<String>,
31    /// Comma-separated field selector. Use a [`SHAPE_*`](crate::shapes)
32    /// 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    /// Joiner used between flattened keys when `flat=true`. Defaults to `.` server-side.
44    #[builder(into)]
45    pub joiner: Option<String>,
46    /// Free-text search filter.
47    #[builder(into)]
48    pub search: Option<String>,
49    /// Vehicle type filter (e.g. `"IDC"`).
50    #[builder(into)]
51    pub vehicle_type: Option<String>,
52    /// Type of IDC filter (e.g. `"GWAC"`).
53    #[builder(into)]
54    pub type_of_idc: Option<String>,
55    /// Contract type filter (e.g. `"FFP"`).
56    #[builder(into)]
57    pub contract_type: Option<String>,
58    /// Set-aside filter (e.g. `"8A"`).
59    #[builder(into)]
60    pub set_aside: Option<String>,
61    /// Who-can-use filter (e.g. `"All"`).
62    #[builder(into)]
63    pub who_can_use: Option<String>,
64    /// NAICS code filter.
65    #[builder(into)]
66    pub naics_code: Option<String>,
67    /// PSC code filter.
68    #[builder(into)]
69    pub psc_code: Option<String>,
70    /// Program acronym filter (e.g. `"OASIS"`).
71    #[builder(into)]
72    pub program_acronym: Option<String>,
73    /// Awarding agency CGAC code.
74    #[builder(into)]
75    pub agency: Option<String>,
76    /// Organization ID filter.
77    #[builder(into)]
78    pub organization_id: Option<String>,
79    /// Lower bound for total obligated dollars (inclusive).
80    #[builder(into)]
81    pub total_obligated_min: Option<String>,
82    /// Upper bound for total obligated dollars (inclusive).
83    #[builder(into)]
84    pub total_obligated_max: Option<String>,
85    /// Lower bound for the number of child IDVs.
86    #[builder(into)]
87    pub idv_count_min: Option<u32>,
88    /// Upper bound for the number of child IDVs.
89    #[builder(into)]
90    pub idv_count_max: Option<u32>,
91    /// Lower bound for the number of orders placed against the vehicle.
92    #[builder(into)]
93    pub order_count_min: Option<u32>,
94    /// Upper bound for the number of orders placed against the vehicle.
95    #[builder(into)]
96    pub order_count_max: Option<u32>,
97    /// `fiscal_year` filter.
98    #[builder(into)]
99    pub fiscal_year: Option<String>,
100    /// Lower bound for award date (ISO `YYYY-MM-DD`).
101    #[builder(into)]
102    pub award_date_after: Option<String>,
103    /// Upper bound for award date (ISO `YYYY-MM-DD`).
104    #[builder(into)]
105    pub award_date_before: Option<String>,
106    /// Lower bound for last date to order.
107    #[builder(into)]
108    pub last_date_to_order_after: Option<String>,
109    /// Upper bound for last date to order.
110    #[builder(into)]
111    pub last_date_to_order_before: Option<String>,
112    /// Server-side sort spec. Server enforces a strict allowlist.
113    #[builder(into)]
114    pub ordering: 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 ListVehiclesOptions {
122    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        push_opt(&mut q, "joiner", self.joiner.as_deref());
134        push_opt(&mut q, "search", self.search.as_deref());
135        push_opt(&mut q, "vehicle_type", self.vehicle_type.as_deref());
136        push_opt(&mut q, "type_of_idc", self.type_of_idc.as_deref());
137        push_opt(&mut q, "contract_type", self.contract_type.as_deref());
138        push_opt(&mut q, "set_aside", self.set_aside.as_deref());
139        push_opt(&mut q, "who_can_use", self.who_can_use.as_deref());
140        push_opt(&mut q, "naics_code", self.naics_code.as_deref());
141        push_opt(&mut q, "psc_code", self.psc_code.as_deref());
142        push_opt(&mut q, "program_acronym", self.program_acronym.as_deref());
143        push_opt(&mut q, "agency", self.agency.as_deref());
144        push_opt(&mut q, "organization_id", self.organization_id.as_deref());
145        push_opt(
146            &mut q,
147            "total_obligated_min",
148            self.total_obligated_min.as_deref(),
149        );
150        push_opt(
151            &mut q,
152            "total_obligated_max",
153            self.total_obligated_max.as_deref(),
154        );
155        push_opt_u32(&mut q, "idv_count_min", self.idv_count_min);
156        push_opt_u32(&mut q, "idv_count_max", self.idv_count_max);
157        push_opt_u32(&mut q, "order_count_min", self.order_count_min);
158        push_opt_u32(&mut q, "order_count_max", self.order_count_max);
159        push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
160        push_opt(&mut q, "award_date_after", self.award_date_after.as_deref());
161        push_opt(
162            &mut q,
163            "award_date_before",
164            self.award_date_before.as_deref(),
165        );
166        push_opt(
167            &mut q,
168            "last_date_to_order_after",
169            self.last_date_to_order_after.as_deref(),
170        );
171        push_opt(
172            &mut q,
173            "last_date_to_order_before",
174            self.last_date_to_order_before.as_deref(),
175        );
176        push_opt(&mut q, "ordering", self.ordering.as_deref());
177
178        for (k, v) in &self.extra {
179            if !v.is_empty() {
180                q.push((k.clone(), v.clone()));
181            }
182        }
183        q
184    }
185}
186
187/// Options for [`Client::get_vehicle`]. Mirrors the Go SDK's
188/// `GetEntityOptions` for the vehicle detail endpoint.
189#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
190#[non_exhaustive]
191pub struct GetVehicleOptions {
192    /// Comma-separated field selector for dynamic response shaping.
193    #[builder(into)]
194    pub shape: Option<String>,
195    /// Collapse nested objects into dot-separated keys.
196    #[builder(default)]
197    pub flat: bool,
198    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
199    #[builder(default)]
200    pub flat_lists: bool,
201}
202
203impl GetVehicleOptions {
204    fn to_query(&self) -> Vec<(String, String)> {
205        let mut q = Vec::new();
206        push_opt(&mut q, "shape", self.shape.as_deref());
207        if self.flat {
208            q.push(("flat".into(), "true".into()));
209        }
210        if self.flat_lists {
211            q.push(("flat_lists".into(), "true".into()));
212        }
213        q
214    }
215}
216
217impl Client {
218    /// `GET /api/vehicles/` — one page of contracting-vehicle records.
219    pub async fn list_vehicles(&self, opts: ListVehiclesOptions) -> Result<Page<Record>> {
220        let q = opts.to_query();
221        let bytes = self.get_bytes("/api/vehicles/", &q).await?;
222        Page::decode(&bytes)
223    }
224
225    /// `GET /api/vehicles/{uuid}/` — fetch a single contracting vehicle.
226    pub async fn get_vehicle(&self, uuid: &str, opts: Option<GetVehicleOptions>) -> Result<Record> {
227        if uuid.is_empty() {
228            return Err(Error::Validation {
229                message: "get_vehicle: uuid is required".into(),
230                response: None,
231            });
232        }
233        let q = opts.unwrap_or_default().to_query();
234        let path = format!("/api/vehicles/{}/", urlencoding(uuid));
235        self.get_json::<Record>(&path, &q).await
236    }
237
238    /// Stream every contracting-vehicle record matching `opts`.
239    pub fn iterate_vehicles(&self, opts: ListVehiclesOptions) -> PageStream<Record> {
240        let opts = Arc::new(opts);
241        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
242            let mut next = (*opts).clone();
243            next.page = page;
244            next.cursor = cursor;
245            Box::pin(async move { client.list_vehicles(next).await })
246        });
247        PageStream::new(self.clone(), fetch)
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
256        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
257    }
258
259    #[test]
260    fn list_vehicles_emits_string_filters() {
261        let opts = ListVehiclesOptions::builder()
262            .search("oasis")
263            .vehicle_type("IDC")
264            .type_of_idc("GWAC")
265            .set_aside("8A")
266            .naics_code("541512")
267            .psc_code("D302")
268            .program_acronym("OASIS")
269            .agency("9700")
270            .fiscal_year("2024")
271            .ordering("total_obligated")
272            .build();
273        let q = opts.to_query();
274        assert_eq!(get_q(&q, "search").as_deref(), Some("oasis"));
275        assert_eq!(get_q(&q, "vehicle_type").as_deref(), Some("IDC"));
276        assert_eq!(get_q(&q, "type_of_idc").as_deref(), Some("GWAC"));
277        assert_eq!(get_q(&q, "set_aside").as_deref(), Some("8A"));
278        assert_eq!(get_q(&q, "naics_code").as_deref(), Some("541512"));
279        assert_eq!(get_q(&q, "psc_code").as_deref(), Some("D302"));
280        assert_eq!(get_q(&q, "program_acronym").as_deref(), Some("OASIS"));
281        assert_eq!(get_q(&q, "agency").as_deref(), Some("9700"));
282        assert_eq!(get_q(&q, "fiscal_year").as_deref(), Some("2024"));
283        assert_eq!(get_q(&q, "ordering").as_deref(), Some("total_obligated"));
284    }
285
286    #[test]
287    fn list_vehicles_emits_int_counts() {
288        let opts = ListVehiclesOptions::builder()
289            .idv_count_min(3u32)
290            .idv_count_max(50u32)
291            .order_count_min(100u32)
292            .order_count_max(9999u32)
293            .build();
294        let q = opts.to_query();
295        assert_eq!(get_q(&q, "idv_count_min").as_deref(), Some("3"));
296        assert_eq!(get_q(&q, "idv_count_max").as_deref(), Some("50"));
297        assert_eq!(get_q(&q, "order_count_min").as_deref(), Some("100"));
298        assert_eq!(get_q(&q, "order_count_max").as_deref(), Some("9999"));
299    }
300
301    #[test]
302    fn list_vehicles_zero_counts_omitted() {
303        let opts = ListVehiclesOptions::builder()
304            .idv_count_min(0u32)
305            .order_count_max(0u32)
306            .build();
307        let q = opts.to_query();
308        assert!(!q.iter().any(|(k, _)| k == "idv_count_min"));
309        assert!(!q.iter().any(|(k, _)| k == "order_count_max"));
310    }
311
312    #[test]
313    fn list_vehicles_extra_map() {
314        let mut extra = BTreeMap::new();
315        extra.insert("version".to_string(), "v2".to_string());
316        let opts = ListVehiclesOptions::builder().extra(extra).build();
317        let q = opts.to_query();
318        assert_eq!(get_q(&q, "version").as_deref(), Some("v2"));
319    }
320
321    #[test]
322    fn get_vehicle_options_emits_shape_and_flat() {
323        let opts = GetVehicleOptions::builder()
324            .shape("vehicles(minimal)")
325            .flat(true)
326            .build();
327        let q = opts.to_query();
328        assert_eq!(get_q(&q, "shape").as_deref(), Some("vehicles(minimal)"));
329        assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
330        assert!(!q.iter().any(|(k, _)| k == "flat_lists"));
331    }
332
333    #[tokio::test]
334    async fn get_vehicle_empty_uuid_returns_validation() {
335        let client = Client::builder().api_key("x").build().expect("build");
336        let err = client.get_vehicle("", None).await.expect_err("must error");
337        match err {
338            Error::Validation { message, .. } => {
339                assert!(message.contains("uuid"));
340            }
341            other => panic!("expected Validation, got {other:?}"),
342        }
343    }
344}