Skip to main content

tango/resources/
contracts.rs

1//! `GET /api/contracts/` — list and stream federal contract records.
2
3use crate::client::Client;
4use crate::error::Result;
5use crate::internal::{apply_pagination, first_non_empty, push_opt};
6use crate::pagination::{FetchFn, Page, PageStream};
7use crate::Record;
8use bon::Builder;
9use std::collections::BTreeMap;
10use std::sync::Arc;
11
12/// Options for [`Client::list_contracts`] and [`Client::iterate_contracts`].
13///
14/// SDK-friendly aliases (`naics_code`, `psc_code`, `recipient_name`,
15/// `recipient_uei`, `set_aside_type`) map to the canonical API names —
16/// passing both prefers the SDK alias to mirror the Node and Python SDKs.
17#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
18#[non_exhaustive]
19pub struct ListContractsOptions {
20    // ----- Pagination + shape (shared across every list endpoint) -----
21    /// 1-based page number. Mutually exclusive with [`cursor`](Self::cursor).
22    #[builder(into)]
23    pub page: Option<u32>,
24    /// Page size (server caps at 100 on most endpoints).
25    #[builder(into)]
26    pub limit: Option<u32>,
27    /// Keyset cursor for cursor-paginated endpoints. Pass the `cursor` field
28    /// from the previous [`Page`](crate::Page).
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    /// 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    /// `award_type` filter (e.g. `"BPA Call"`).
53    #[builder(into)]
54    pub award_type: Option<String>,
55
56    /// `fiscal_year` filter (accepts `"2024"`, `"FY24"`, or range expressions).
57    #[builder(into)]
58    pub fiscal_year: Option<String>,
59    /// Lower bound for `fiscal_year`.
60    #[builder(into)]
61    pub fiscal_year_gte: Option<String>,
62    /// Upper bound for `fiscal_year`.
63    #[builder(into)]
64    pub fiscal_year_lte: Option<String>,
65
66    /// Lower bound for `obligated` (dollars).
67    #[builder(into)]
68    pub obligated_gte: Option<String>,
69    /// Upper bound for `obligated` (dollars).
70    #[builder(into)]
71    pub obligated_lte: Option<String>,
72
73    /// Lower bound for period-of-performance start date.
74    #[builder(into)]
75    pub pop_start_date_gte: Option<String>,
76    /// Upper bound for period-of-performance start date.
77    #[builder(into)]
78    pub pop_start_date_lte: Option<String>,
79    /// Lower bound for period-of-performance end date.
80    #[builder(into)]
81    pub pop_end_date_gte: Option<String>,
82    /// Upper bound for period-of-performance end date.
83    #[builder(into)]
84    pub pop_end_date_lte: Option<String>,
85    /// Lower bound for contract expiration date.
86    #[builder(into)]
87    pub expiring_gte: Option<String>,
88    /// Upper bound for contract expiration date.
89    #[builder(into)]
90    pub expiring_lte: Option<String>,
91
92    /// Awarding agency CGAC code (e.g. `"9700"`).
93    #[builder(into)]
94    pub awarding_agency: Option<String>,
95    /// Funding agency CGAC code.
96    #[builder(into)]
97    pub funding_agency: Option<String>,
98    /// Procurement Instrument Identifier filter.
99    #[builder(into)]
100    pub piid: Option<String>,
101    /// Solicitation identifier filter.
102    #[builder(into)]
103    pub solicitation_identifier: Option<String>,
104    /// NAICS code (canonical name).
105    #[builder(into)]
106    pub naics: Option<String>,
107    /// PSC code (canonical name).
108    #[builder(into)]
109    pub psc: Option<String>,
110    /// Recipient name filter (canonical name).
111    #[builder(into)]
112    pub recipient: Option<String>,
113    /// Recipient UEI filter (canonical name).
114    #[builder(into)]
115    pub uei: Option<String>,
116    /// Set-aside filter (canonical name).
117    #[builder(into)]
118    pub set_aside: Option<String>,
119
120    // SDK-friendly aliases mirroring Node/Python:
121    /// SDK-friendly alias for [`naics`](Self::naics).
122    #[builder(into)]
123    pub naics_code: Option<String>,
124    /// SDK-friendly alias for [`psc`](Self::psc).
125    #[builder(into)]
126    pub psc_code: Option<String>,
127    /// SDK-friendly alias for [`recipient`](Self::recipient).
128    #[builder(into)]
129    pub recipient_name: Option<String>,
130    /// SDK-friendly alias for [`uei`](Self::uei).
131    #[builder(into)]
132    pub recipient_uei: Option<String>,
133    /// SDK-friendly alias for [`set_aside`](Self::set_aside).
134    #[builder(into)]
135    pub set_aside_type: Option<String>,
136
137    /// Free-text search filter.
138    #[builder(into)]
139    pub search: Option<String>,
140    /// SDK-friendly alias for [`search`](Self::search).
141    #[builder(into)]
142    pub keyword: Option<String>,
143    /// Server-side sort spec (e.g. `"obligated"`, prefix `-` for descending).
144    #[builder(into)]
145    pub ordering: Option<String>,
146    /// Sort field — combined with [`order`](Self::order) into `ordering`.
147    #[builder(into)]
148    pub sort: Option<String>,
149    /// `"asc"` or `"desc"`. Only meaningful when `sort` is set.
150    #[builder(into)]
151    pub order: Option<String>,
152
153    /// Escape hatch for filter keys not yet first-classed on this struct.
154    #[builder(default)]
155    pub extra: BTreeMap<String, String>,
156}
157
158impl ListContractsOptions {
159    fn to_query(&self) -> Vec<(String, String)> {
160        let mut q = Vec::new();
161        apply_pagination(
162            &mut q,
163            self.page,
164            self.limit,
165            self.cursor.as_deref(),
166            self.shape.as_deref(),
167            self.flat,
168            self.flat_lists,
169        );
170
171        push_opt(&mut q, "award_date", self.award_date.as_deref());
172        push_opt(&mut q, "award_date_gte", self.award_date_gte.as_deref());
173        push_opt(&mut q, "award_date_lte", self.award_date_lte.as_deref());
174        push_opt(&mut q, "award_type", self.award_type.as_deref());
175        push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
176        push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
177        push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
178        push_opt(&mut q, "obligated_gte", self.obligated_gte.as_deref());
179        push_opt(&mut q, "obligated_lte", self.obligated_lte.as_deref());
180        push_opt(
181            &mut q,
182            "pop_start_date_gte",
183            self.pop_start_date_gte.as_deref(),
184        );
185        push_opt(
186            &mut q,
187            "pop_start_date_lte",
188            self.pop_start_date_lte.as_deref(),
189        );
190        push_opt(&mut q, "pop_end_date_gte", self.pop_end_date_gte.as_deref());
191        push_opt(&mut q, "pop_end_date_lte", self.pop_end_date_lte.as_deref());
192        push_opt(&mut q, "expiring_gte", self.expiring_gte.as_deref());
193        push_opt(&mut q, "expiring_lte", self.expiring_lte.as_deref());
194        push_opt(&mut q, "awarding_agency", self.awarding_agency.as_deref());
195        push_opt(&mut q, "funding_agency", self.funding_agency.as_deref());
196        push_opt(&mut q, "piid", self.piid.as_deref());
197        push_opt(
198            &mut q,
199            "solicitation_identifier",
200            self.solicitation_identifier.as_deref(),
201        );
202
203        // SDK aliases win over canonical names when both are set.
204        push_opt(
205            &mut q,
206            "naics",
207            first_non_empty(&[self.naics_code.as_deref(), self.naics.as_deref()]),
208        );
209        push_opt(
210            &mut q,
211            "psc",
212            first_non_empty(&[self.psc_code.as_deref(), self.psc.as_deref()]),
213        );
214        push_opt(
215            &mut q,
216            "recipient",
217            first_non_empty(&[self.recipient_name.as_deref(), self.recipient.as_deref()]),
218        );
219        push_opt(
220            &mut q,
221            "uei",
222            first_non_empty(&[self.recipient_uei.as_deref(), self.uei.as_deref()]),
223        );
224        push_opt(
225            &mut q,
226            "set_aside",
227            first_non_empty(&[self.set_aside_type.as_deref(), self.set_aside.as_deref()]),
228        );
229        push_opt(
230            &mut q,
231            "search",
232            first_non_empty(&[self.keyword.as_deref(), self.search.as_deref()]),
233        );
234
235        if let Some(sort) = self.sort.as_deref().filter(|s| !s.is_empty()) {
236            let order = self.order.as_deref().unwrap_or("");
237            let prefix = if order == "desc" { "-" } else { "" };
238            q.push(("ordering".into(), format!("{prefix}{sort}")));
239        } else {
240            push_opt(&mut q, "ordering", self.ordering.as_deref());
241        }
242
243        for (k, v) in &self.extra {
244            if !v.is_empty() {
245                q.push((k.clone(), v.clone()));
246            }
247        }
248        q
249    }
250}
251
252impl Client {
253    /// `GET /api/contracts/` — one page of federal contract records.
254    pub async fn list_contracts(&self, opts: ListContractsOptions) -> Result<Page<Record>> {
255        let q = opts.to_query();
256        let bytes = self.get_bytes("/api/contracts/", &q).await?;
257        Page::decode(&bytes)
258    }
259
260    /// Stream every federal contract record matching `opts`. The stream follows
261    /// `?cursor=` (or `?page=` fallback) on the server's `next` URL.
262    ///
263    /// ```no_run
264    /// # use tango::{Client, ListContractsOptions};
265    /// # use futures::TryStreamExt;
266    /// # async fn run(client: Client) -> tango::Result<()> {
267    /// let mut s = client.iterate_contracts(
268    ///     ListContractsOptions::builder().awarding_agency("9700").build(),
269    /// );
270    /// while let Some(record) = s.try_next().await? {
271    ///     let _ = record;
272    /// }
273    /// # Ok(()) }
274    /// ```
275    pub fn iterate_contracts(&self, opts: ListContractsOptions) -> PageStream<Record> {
276        let opts = Arc::new(opts);
277        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
278            let mut next = (*opts).clone();
279            next.page = page;
280            next.cursor = cursor;
281            Box::pin(async move { client.list_contracts(next).await })
282        });
283        PageStream::new(self.clone(), fetch)
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
292        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
293    }
294
295    #[test]
296    fn alias_prefers_sdk_name_over_canonical() {
297        let opts = ListContractsOptions::builder()
298            .naics_code("541512")
299            .naics("999999")
300            .build();
301        let q = opts.to_query();
302        assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
303    }
304
305    #[test]
306    fn alias_falls_through_to_canonical() {
307        let opts = ListContractsOptions::builder().naics("541512").build();
308        let q = opts.to_query();
309        assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
310    }
311
312    #[test]
313    fn sort_plus_order_builds_ordering() {
314        let opts = ListContractsOptions::builder()
315            .sort("obligated")
316            .order("desc")
317            .build();
318        let q = opts.to_query();
319        assert_eq!(get_q(&q, "ordering").as_deref(), Some("-obligated"));
320    }
321
322    #[test]
323    fn sort_without_order_defaults_ascending() {
324        let opts = ListContractsOptions::builder().sort("award_date").build();
325        let q = opts.to_query();
326        assert_eq!(get_q(&q, "ordering").as_deref(), Some("award_date"));
327    }
328
329    #[test]
330    fn cursor_wins_over_page() {
331        let opts = ListContractsOptions::builder()
332            .page(3u32)
333            .cursor("abc".to_string())
334            .build();
335        let q = opts.to_query();
336        assert_eq!(get_q(&q, "cursor").as_deref(), Some("abc"));
337        assert_eq!(get_q(&q, "page"), None);
338    }
339
340    #[test]
341    fn shape_emits() {
342        let opts = ListContractsOptions::builder()
343            .shape(crate::SHAPE_CONTRACTS_MINIMAL)
344            .build();
345        let q = opts.to_query();
346        assert_eq!(
347            get_q(&q, "shape").as_deref(),
348            Some(crate::SHAPE_CONTRACTS_MINIMAL)
349        );
350    }
351}