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::{Error, Result};
5use crate::internal::{apply_pagination, first_non_empty, push_opt, ListOptions};
6use crate::pagination::{FetchFn, Page, PageStream};
7use crate::resources::agencies::urlencoding;
8use crate::resources::entity_subresources::EntitySubresourceOptions;
9use crate::Record;
10use bon::Builder;
11use std::collections::BTreeMap;
12use std::sync::Arc;
13
14/// Options for [`Client::list_contracts`] and [`Client::iterate_contracts`].
15///
16/// SDK-friendly aliases (`naics_code`, `psc_code`, `recipient_name`,
17/// `recipient_uei`, `set_aside_type`) map to the canonical API names —
18/// passing both prefers the SDK alias to mirror the Node and Python SDKs.
19#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
20#[non_exhaustive]
21pub struct ListContractsOptions {
22    // ----- Pagination + shape (shared across every list endpoint) -----
23    /// 1-based page number. Mutually exclusive with [`cursor`](Self::cursor).
24    #[builder(into)]
25    pub page: Option<u32>,
26    /// Page size (server caps at 100 on most endpoints).
27    #[builder(into)]
28    pub limit: Option<u32>,
29    /// Keyset cursor for cursor-paginated endpoints. Pass the `cursor` field
30    /// from the previous [`Page`](crate::Page).
31    #[builder(into)]
32    pub cursor: Option<String>,
33    /// Comma-separated field selector. Use a [`SHAPE_*`](crate::shapes)
34    /// constant or roll your own.
35    #[builder(into)]
36    pub shape: Option<String>,
37    /// Collapse nested objects into dot-separated keys.
38    #[builder(default)]
39    pub flat: bool,
40    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
41    #[builder(default)]
42    pub flat_lists: bool,
43
44    // ----- Resource filters -----
45    /// Single-day `award_date` filter (ISO `YYYY-MM-DD`).
46    #[builder(into)]
47    pub award_date: Option<String>,
48    /// Lower bound for `award_date` (inclusive).
49    #[builder(into)]
50    pub award_date_gte: Option<String>,
51    /// Upper bound for `award_date` (inclusive).
52    #[builder(into)]
53    pub award_date_lte: Option<String>,
54    /// `award_type` filter (e.g. `"BPA Call"`).
55    #[builder(into)]
56    pub award_type: Option<String>,
57
58    /// `fiscal_year` filter (accepts `"2024"`, `"FY24"`, or range expressions).
59    #[builder(into)]
60    pub fiscal_year: Option<String>,
61    /// Lower bound for `fiscal_year`.
62    #[builder(into)]
63    pub fiscal_year_gte: Option<String>,
64    /// Upper bound for `fiscal_year`.
65    #[builder(into)]
66    pub fiscal_year_lte: Option<String>,
67
68    /// Lower bound for `obligated` (dollars).
69    #[builder(into)]
70    pub obligated_gte: Option<String>,
71    /// Upper bound for `obligated` (dollars).
72    #[builder(into)]
73    pub obligated_lte: Option<String>,
74
75    /// Lower bound for period-of-performance start date.
76    #[builder(into)]
77    pub pop_start_date_gte: Option<String>,
78    /// Upper bound for period-of-performance start date.
79    #[builder(into)]
80    pub pop_start_date_lte: Option<String>,
81    /// Lower bound for period-of-performance end date.
82    #[builder(into)]
83    pub pop_end_date_gte: Option<String>,
84    /// Upper bound for period-of-performance end date.
85    #[builder(into)]
86    pub pop_end_date_lte: Option<String>,
87    /// Lower bound for contract expiration date.
88    #[builder(into)]
89    pub expiring_gte: Option<String>,
90    /// Upper bound for contract expiration date.
91    #[builder(into)]
92    pub expiring_lte: Option<String>,
93
94    /// Awarding agency CGAC code (e.g. `"9700"`).
95    #[builder(into)]
96    pub awarding_agency: Option<String>,
97    /// Funding agency CGAC code.
98    #[builder(into)]
99    pub funding_agency: Option<String>,
100    /// Procurement Instrument Identifier filter.
101    #[builder(into)]
102    pub piid: Option<String>,
103    /// Solicitation identifier filter.
104    #[builder(into)]
105    pub solicitation_identifier: Option<String>,
106    /// NAICS code (canonical name).
107    #[builder(into)]
108    pub naics: Option<String>,
109    /// PSC code (canonical name).
110    #[builder(into)]
111    pub psc: Option<String>,
112    /// Recipient name filter (canonical name).
113    #[builder(into)]
114    pub recipient: Option<String>,
115    /// Recipient UEI filter (canonical name).
116    #[builder(into)]
117    pub uei: Option<String>,
118    /// Set-aside filter (canonical name).
119    #[builder(into)]
120    pub set_aside: Option<String>,
121
122    // SDK-friendly aliases mirroring Node/Python:
123    /// SDK-friendly alias for [`naics`](Self::naics).
124    #[builder(into)]
125    pub naics_code: Option<String>,
126    /// SDK-friendly alias for [`psc`](Self::psc).
127    #[builder(into)]
128    pub psc_code: Option<String>,
129    /// SDK-friendly alias for [`recipient`](Self::recipient).
130    #[builder(into)]
131    pub recipient_name: Option<String>,
132    /// SDK-friendly alias for [`uei`](Self::uei).
133    #[builder(into)]
134    pub recipient_uei: Option<String>,
135    /// SDK-friendly alias for [`set_aside`](Self::set_aside).
136    #[builder(into)]
137    pub set_aside_type: Option<String>,
138
139    /// Free-text search filter.
140    #[builder(into)]
141    pub search: Option<String>,
142    /// SDK-friendly alias for [`search`](Self::search).
143    #[builder(into)]
144    pub keyword: Option<String>,
145    /// Server-side sort spec (e.g. `"obligated"`, prefix `-` for descending).
146    #[builder(into)]
147    pub ordering: Option<String>,
148    /// Sort field — combined with [`order`](Self::order) into `ordering`.
149    #[builder(into)]
150    pub sort: Option<String>,
151    /// `"asc"` or `"desc"`. Only meaningful when `sort` is set.
152    #[builder(into)]
153    pub order: Option<String>,
154
155    /// Filter by Tango award key (the detail-endpoint identifier). Supports multi-value OR via `|`.
156    #[builder(into)]
157    pub key: Option<String>,
158
159    /// Escape hatch for filter keys not yet first-classed on this struct.
160    #[builder(default)]
161    pub extra: BTreeMap<String, String>,
162}
163
164impl ListContractsOptions {
165    fn to_query(&self) -> Vec<(String, String)> {
166        let mut q = Vec::new();
167        apply_pagination(
168            &mut q,
169            self.page,
170            self.limit,
171            self.cursor.as_deref(),
172            self.shape.as_deref(),
173            self.flat,
174            self.flat_lists,
175        );
176
177        push_opt(&mut q, "award_date", self.award_date.as_deref());
178        push_opt(&mut q, "award_date_gte", self.award_date_gte.as_deref());
179        push_opt(&mut q, "award_date_lte", self.award_date_lte.as_deref());
180        push_opt(&mut q, "award_type", self.award_type.as_deref());
181        push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
182        push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
183        push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
184        push_opt(&mut q, "obligated_gte", self.obligated_gte.as_deref());
185        push_opt(&mut q, "obligated_lte", self.obligated_lte.as_deref());
186        push_opt(
187            &mut q,
188            "pop_start_date_gte",
189            self.pop_start_date_gte.as_deref(),
190        );
191        push_opt(
192            &mut q,
193            "pop_start_date_lte",
194            self.pop_start_date_lte.as_deref(),
195        );
196        push_opt(&mut q, "pop_end_date_gte", self.pop_end_date_gte.as_deref());
197        push_opt(&mut q, "pop_end_date_lte", self.pop_end_date_lte.as_deref());
198        push_opt(&mut q, "expiring_gte", self.expiring_gte.as_deref());
199        push_opt(&mut q, "expiring_lte", self.expiring_lte.as_deref());
200        push_opt(&mut q, "awarding_agency", self.awarding_agency.as_deref());
201        push_opt(&mut q, "funding_agency", self.funding_agency.as_deref());
202        push_opt(&mut q, "piid", self.piid.as_deref());
203        push_opt(&mut q, "key", self.key.as_deref());
204        push_opt(
205            &mut q,
206            "solicitation_identifier",
207            self.solicitation_identifier.as_deref(),
208        );
209
210        // SDK aliases win over canonical names when both are set.
211        push_opt(
212            &mut q,
213            "naics",
214            first_non_empty(&[self.naics_code.as_deref(), self.naics.as_deref()]),
215        );
216        push_opt(
217            &mut q,
218            "psc",
219            first_non_empty(&[self.psc_code.as_deref(), self.psc.as_deref()]),
220        );
221        push_opt(
222            &mut q,
223            "recipient",
224            first_non_empty(&[self.recipient_name.as_deref(), self.recipient.as_deref()]),
225        );
226        push_opt(
227            &mut q,
228            "uei",
229            first_non_empty(&[self.recipient_uei.as_deref(), self.uei.as_deref()]),
230        );
231        push_opt(
232            &mut q,
233            "set_aside",
234            first_non_empty(&[self.set_aside_type.as_deref(), self.set_aside.as_deref()]),
235        );
236        push_opt(
237            &mut q,
238            "search",
239            first_non_empty(&[self.keyword.as_deref(), self.search.as_deref()]),
240        );
241
242        if let Some(sort) = self.sort.as_deref().filter(|s| !s.is_empty()) {
243            let order = self.order.as_deref().unwrap_or("");
244            let prefix = if order == "desc" { "-" } else { "" };
245            q.push(("ordering".into(), format!("{prefix}{sort}")));
246        } else {
247            push_opt(&mut q, "ordering", self.ordering.as_deref());
248        }
249
250        for (k, v) in &self.extra {
251            if !v.is_empty() {
252                q.push((k.clone(), v.clone()));
253            }
254        }
255        q
256    }
257}
258
259impl Client {
260    /// `GET /api/contracts/` — one page of federal contract records.
261    pub async fn list_contracts(&self, opts: ListContractsOptions) -> Result<Page<Record>> {
262        let q = opts.to_query();
263        let bytes = self.get_bytes("/api/contracts/", &q).await?;
264        Page::decode(&bytes)
265    }
266
267    /// `GET /api/contracts/{key}/` — a single federal contract record.
268    pub async fn get_contract(&self, key: &str, opts: Option<ListOptions>) -> Result<Record> {
269        if key.is_empty() {
270            return Err(Error::Validation {
271                message: "get_contract: key is required".into(),
272                response: None,
273            });
274        }
275        let mut q = Vec::new();
276        opts.unwrap_or_default().apply(&mut q);
277        let path = format!("/api/contracts/{}/", urlencoding(key));
278        self.get_json::<Record>(&path, &q).await
279    }
280
281    /// `GET /api/contracts/{key}/subawards/` — subawards reported against a
282    /// single prime contract.
283    pub async fn list_contract_subawards(
284        &self,
285        key: &str,
286        opts: Option<EntitySubresourceOptions>,
287    ) -> Result<Page<Record>> {
288        if key.is_empty() {
289            return Err(Error::Validation {
290                message: "list_contract_subawards: key is required".into(),
291                response: None,
292            });
293        }
294        let q = opts.unwrap_or_default().to_query();
295        let path = format!("/api/contracts/{}/subawards/", urlencoding(key));
296        let bytes = self.get_bytes(&path, &q).await?;
297        Page::decode(&bytes)
298    }
299
300    /// `GET /api/contracts/{key}/transactions/` — raw transaction history
301    /// backing a single contract.
302    pub async fn list_contract_transactions(
303        &self,
304        key: &str,
305        opts: Option<EntitySubresourceOptions>,
306    ) -> Result<Page<Record>> {
307        if key.is_empty() {
308            return Err(Error::Validation {
309                message: "list_contract_transactions: key is required".into(),
310                response: None,
311            });
312        }
313        let q = opts.unwrap_or_default().to_query();
314        let path = format!("/api/contracts/{}/transactions/", urlencoding(key));
315        let bytes = self.get_bytes(&path, &q).await?;
316        Page::decode(&bytes)
317    }
318
319    /// Stream every federal contract record matching `opts`. The stream follows
320    /// `?cursor=` (or `?page=` fallback) on the server's `next` URL.
321    ///
322    /// ```no_run
323    /// # use tango::{Client, ListContractsOptions};
324    /// # use futures::TryStreamExt;
325    /// # async fn run(client: Client) -> tango::Result<()> {
326    /// let mut s = client.iterate_contracts(
327    ///     ListContractsOptions::builder().awarding_agency("9700").build(),
328    /// );
329    /// while let Some(record) = s.try_next().await? {
330    ///     let _ = record;
331    /// }
332    /// # Ok(()) }
333    /// ```
334    pub fn iterate_contracts(&self, opts: ListContractsOptions) -> PageStream<Record> {
335        let opts = Arc::new(opts);
336        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
337            let mut next = (*opts).clone();
338            next.page = page;
339            next.cursor = cursor;
340            Box::pin(async move { client.list_contracts(next).await })
341        });
342        PageStream::new(self.clone(), fetch)
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
351        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
352    }
353
354    #[test]
355    fn alias_prefers_sdk_name_over_canonical() {
356        let opts = ListContractsOptions::builder()
357            .naics_code("541512")
358            .naics("999999")
359            .build();
360        let q = opts.to_query();
361        assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
362    }
363
364    #[test]
365    fn alias_falls_through_to_canonical() {
366        let opts = ListContractsOptions::builder().naics("541512").build();
367        let q = opts.to_query();
368        assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
369    }
370
371    #[test]
372    fn sort_plus_order_builds_ordering() {
373        let opts = ListContractsOptions::builder()
374            .sort("obligated")
375            .order("desc")
376            .build();
377        let q = opts.to_query();
378        assert_eq!(get_q(&q, "ordering").as_deref(), Some("-obligated"));
379    }
380
381    #[test]
382    fn sort_without_order_defaults_ascending() {
383        let opts = ListContractsOptions::builder().sort("award_date").build();
384        let q = opts.to_query();
385        assert_eq!(get_q(&q, "ordering").as_deref(), Some("award_date"));
386    }
387
388    #[test]
389    fn cursor_wins_over_page() {
390        let opts = ListContractsOptions::builder()
391            .page(3u32)
392            .cursor("abc".to_string())
393            .build();
394        let q = opts.to_query();
395        assert_eq!(get_q(&q, "cursor").as_deref(), Some("abc"));
396        assert_eq!(get_q(&q, "page"), None);
397    }
398
399    #[test]
400    fn shape_emits() {
401        let opts = ListContractsOptions::builder()
402            .shape(crate::SHAPE_CONTRACTS_MINIMAL)
403            .build();
404        let q = opts.to_query();
405        assert_eq!(
406            get_q(&q, "shape").as_deref(),
407            Some(crate::SHAPE_CONTRACTS_MINIMAL)
408        );
409    }
410
411    #[tokio::test]
412    async fn get_contract_empty_key_returns_validation() {
413        let client = Client::builder().api_key("x").build().expect("build");
414        let err = client.get_contract("", None).await.expect_err("must error");
415        match err {
416            Error::Validation { message, .. } => assert!(message.contains("key")),
417            other => panic!("expected Validation, got {other:?}"),
418        }
419    }
420
421    #[tokio::test]
422    async fn list_contract_subawards_empty_key_returns_validation() {
423        let client = Client::builder().api_key("x").build().expect("build");
424        let err = client
425            .list_contract_subawards("", None)
426            .await
427            .expect_err("must error");
428        match err {
429            Error::Validation { message, .. } => assert!(message.contains("key")),
430            other => panic!("expected Validation, got {other:?}"),
431        }
432    }
433
434    #[tokio::test]
435    async fn list_contract_transactions_empty_key_returns_validation() {
436        let client = Client::builder().api_key("x").build().expect("build");
437        let err = client
438            .list_contract_transactions("", None)
439            .await
440            .expect_err("must error");
441        match err {
442            Error::Validation { message, .. } => assert!(message.contains("key")),
443            other => panic!("expected Validation, got {other:?}"),
444        }
445    }
446
447    #[test]
448    fn list_contracts_key_filter_emits() {
449        let q = ListContractsOptions::builder()
450            .key("K1|K2")
451            .build()
452            .to_query();
453        assert_eq!(get_q(&q, "key").as_deref(), Some("K1|K2"));
454    }
455}