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). Several UEIs joined by `|` match awards to any of them, up to 500 (Tango API 5.11.0).
116    #[builder(into)]
117    pub uei: Option<String>,
118    /// 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.
119    #[builder(into)]
120    pub commercial_item_acquisition_procedures: Option<String>,
121    /// Set-aside filter (canonical name).
122    #[builder(into)]
123    pub set_aside: Option<String>,
124
125    // SDK-friendly aliases mirroring Node/Python:
126    /// SDK-friendly alias for [`naics`](Self::naics).
127    #[builder(into)]
128    pub naics_code: Option<String>,
129    /// SDK-friendly alias for [`psc`](Self::psc).
130    #[builder(into)]
131    pub psc_code: Option<String>,
132    /// SDK-friendly alias for [`recipient`](Self::recipient).
133    #[builder(into)]
134    pub recipient_name: Option<String>,
135    /// SDK-friendly alias for [`uei`](Self::uei). Several UEIs joined by `|` match awards to any of them, up to 500 (Tango API 5.11.0).
136    #[builder(into)]
137    pub recipient_uei: Option<String>,
138    /// SDK-friendly alias for [`set_aside`](Self::set_aside).
139    #[builder(into)]
140    pub set_aside_type: Option<String>,
141
142    /// Free-text search filter.
143    #[builder(into)]
144    pub search: Option<String>,
145    /// SDK-friendly alias for [`search`](Self::search).
146    #[builder(into)]
147    pub keyword: Option<String>,
148    /// Server-side sort spec (e.g. `"obligated"`, prefix `-` for descending).
149    #[builder(into)]
150    pub ordering: Option<String>,
151    /// Sort field — combined with [`order`](Self::order) into `ordering`.
152    #[builder(into)]
153    pub sort: Option<String>,
154    /// `"asc"` or `"desc"`. Only meaningful when `sort` is set.
155    #[builder(into)]
156    pub order: Option<String>,
157
158    /// Filter by Tango award key (the detail-endpoint identifier). Supports multi-value OR via `|`.
159    #[builder(into)]
160    pub key: Option<String>,
161
162    /// Escape hatch for filter keys not yet first-classed on this struct.
163    #[builder(default)]
164    pub extra: BTreeMap<String, String>,
165}
166
167impl ListContractsOptions {
168    fn to_query(&self) -> Vec<(String, String)> {
169        let mut q = Vec::new();
170        apply_pagination(
171            &mut q,
172            self.page,
173            self.limit,
174            self.cursor.as_deref(),
175            self.shape.as_deref(),
176            self.flat,
177            self.flat_lists,
178        );
179
180        push_opt(&mut q, "award_date", self.award_date.as_deref());
181        push_opt(&mut q, "award_date_gte", self.award_date_gte.as_deref());
182        push_opt(&mut q, "award_date_lte", self.award_date_lte.as_deref());
183        push_opt(&mut q, "award_type", self.award_type.as_deref());
184        push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
185        push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
186        push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
187        push_opt(&mut q, "obligated_gte", self.obligated_gte.as_deref());
188        push_opt(&mut q, "obligated_lte", self.obligated_lte.as_deref());
189        push_opt(
190            &mut q,
191            "pop_start_date_gte",
192            self.pop_start_date_gte.as_deref(),
193        );
194        push_opt(
195            &mut q,
196            "pop_start_date_lte",
197            self.pop_start_date_lte.as_deref(),
198        );
199        push_opt(&mut q, "pop_end_date_gte", self.pop_end_date_gte.as_deref());
200        push_opt(&mut q, "pop_end_date_lte", self.pop_end_date_lte.as_deref());
201        push_opt(&mut q, "expiring_gte", self.expiring_gte.as_deref());
202        push_opt(&mut q, "expiring_lte", self.expiring_lte.as_deref());
203        push_opt(&mut q, "awarding_agency", self.awarding_agency.as_deref());
204        push_opt(&mut q, "funding_agency", self.funding_agency.as_deref());
205        push_opt(&mut q, "piid", self.piid.as_deref());
206        push_opt(&mut q, "key", self.key.as_deref());
207        push_opt(
208            &mut q,
209            "solicitation_identifier",
210            self.solicitation_identifier.as_deref(),
211        );
212
213        // SDK aliases win over canonical names when both are set.
214        push_opt(
215            &mut q,
216            "naics",
217            first_non_empty(&[self.naics_code.as_deref(), self.naics.as_deref()]),
218        );
219        push_opt(
220            &mut q,
221            "psc",
222            first_non_empty(&[self.psc_code.as_deref(), self.psc.as_deref()]),
223        );
224        push_opt(
225            &mut q,
226            "recipient",
227            first_non_empty(&[self.recipient_name.as_deref(), self.recipient.as_deref()]),
228        );
229        push_opt(
230            &mut q,
231            "uei",
232            first_non_empty(&[self.recipient_uei.as_deref(), self.uei.as_deref()]),
233        );
234        push_opt(
235            &mut q,
236            "commercial_item_acquisition_procedures",
237            self.commercial_item_acquisition_procedures.as_deref(),
238        );
239        push_opt(
240            &mut q,
241            "set_aside",
242            first_non_empty(&[self.set_aside_type.as_deref(), self.set_aside.as_deref()]),
243        );
244        push_opt(
245            &mut q,
246            "search",
247            first_non_empty(&[self.keyword.as_deref(), self.search.as_deref()]),
248        );
249
250        if let Some(sort) = self.sort.as_deref().filter(|s| !s.is_empty()) {
251            let order = self.order.as_deref().unwrap_or("");
252            let prefix = if order == "desc" { "-" } else { "" };
253            q.push(("ordering".into(), format!("{prefix}{sort}")));
254        } else {
255            push_opt(&mut q, "ordering", self.ordering.as_deref());
256        }
257
258        for (k, v) in &self.extra {
259            if !v.is_empty() {
260                q.push((k.clone(), v.clone()));
261            }
262        }
263        q
264    }
265}
266
267impl Client {
268    /// `GET /api/contracts/` — one page of federal contract records.
269    ///
270    /// Name `obligations_by_fiscal_year(fiscal_year,obligated)` in `shape` for the award's obligations per federal fiscal year (Tango API 5.11.0), and decode it with [`FiscalYearObligation::from_record`](crate::models::FiscalYearObligation::from_record).
271    /// The same expand works on the IDV, OTA and OT IDV lists and every award `get_*`, but it is always null on IDVs and OT IDVs.
272    pub async fn list_contracts(&self, opts: ListContractsOptions) -> Result<Page<Record>> {
273        let q = opts.to_query();
274        self.get_page("/api/contracts/", &q).await
275    }
276
277    /// `GET /api/contracts/{key}/` — a single federal contract record.
278    pub async fn get_contract(&self, key: &str, opts: Option<ListOptions>) -> Result<Record> {
279        if key.is_empty() {
280            return Err(Error::Validation {
281                message: "get_contract: key is required".into(),
282                response: None,
283            });
284        }
285        let mut q = Vec::new();
286        opts.unwrap_or_default().apply(&mut q);
287        let path = format!("/api/contracts/{}/", urlencoding(key));
288        self.get_json::<Record>(&path, &q).await
289    }
290
291    /// `GET /api/contracts/{key}/subawards/` — subawards reported against a
292    /// single prime contract.
293    pub async fn list_contract_subawards(
294        &self,
295        key: &str,
296        opts: Option<EntitySubresourceOptions>,
297    ) -> Result<Page<Record>> {
298        if key.is_empty() {
299            return Err(Error::Validation {
300                message: "list_contract_subawards: key is required".into(),
301                response: None,
302            });
303        }
304        let q = opts.unwrap_or_default().to_query();
305        let path = format!("/api/contracts/{}/subawards/", urlencoding(key));
306        self.get_page(&path, &q).await
307    }
308
309    /// `GET /api/contracts/{key}/transactions/` — raw transaction history
310    /// backing a single contract.
311    pub async fn list_contract_transactions(
312        &self,
313        key: &str,
314        opts: Option<EntitySubresourceOptions>,
315    ) -> Result<Page<Record>> {
316        if key.is_empty() {
317            return Err(Error::Validation {
318                message: "list_contract_transactions: key is required".into(),
319                response: None,
320            });
321        }
322        let q = opts.unwrap_or_default().to_query();
323        let path = format!("/api/contracts/{}/transactions/", urlencoding(key));
324        self.get_page(&path, &q).await
325    }
326
327    /// Stream every federal contract record matching `opts`. The stream follows
328    /// `?cursor=` (or `?page=` fallback) on the server's `next` URL.
329    ///
330    /// ```no_run
331    /// # use tango::{Client, ListContractsOptions};
332    /// # use futures::TryStreamExt;
333    /// # async fn run(client: Client) -> tango::Result<()> {
334    /// let mut s = client.iterate_contracts(
335    ///     ListContractsOptions::builder().awarding_agency("9700").build(),
336    /// );
337    /// while let Some(record) = s.try_next().await? {
338    ///     let _ = record;
339    /// }
340    /// # Ok(()) }
341    /// ```
342    pub fn iterate_contracts(&self, opts: ListContractsOptions) -> PageStream<Record> {
343        let opts = Arc::new(opts);
344        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
345            let mut next = (*opts).clone();
346            next.page = page;
347            next.cursor = cursor;
348            Box::pin(async move { client.list_contracts(next).await })
349        });
350        PageStream::new(self.clone(), fetch)
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
359        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
360    }
361
362    #[test]
363    fn alias_prefers_sdk_name_over_canonical() {
364        let opts = ListContractsOptions::builder()
365            .naics_code("541512")
366            .naics("999999")
367            .build();
368        let q = opts.to_query();
369        assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
370    }
371
372    #[test]
373    fn alias_falls_through_to_canonical() {
374        let opts = ListContractsOptions::builder().naics("541512").build();
375        let q = opts.to_query();
376        assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
377    }
378
379    #[test]
380    fn sort_plus_order_builds_ordering() {
381        let opts = ListContractsOptions::builder()
382            .sort("obligated")
383            .order("desc")
384            .build();
385        let q = opts.to_query();
386        assert_eq!(get_q(&q, "ordering").as_deref(), Some("-obligated"));
387    }
388
389    #[test]
390    fn sort_without_order_defaults_ascending() {
391        let opts = ListContractsOptions::builder().sort("award_date").build();
392        let q = opts.to_query();
393        assert_eq!(get_q(&q, "ordering").as_deref(), Some("award_date"));
394    }
395
396    #[test]
397    fn cursor_wins_over_page() {
398        let opts = ListContractsOptions::builder()
399            .page(3u32)
400            .cursor("abc".to_string())
401            .build();
402        let q = opts.to_query();
403        assert_eq!(get_q(&q, "cursor").as_deref(), Some("abc"));
404        assert_eq!(get_q(&q, "page"), None);
405    }
406
407    #[test]
408    fn shape_emits() {
409        let opts = ListContractsOptions::builder()
410            .shape(crate::SHAPE_CONTRACTS_MINIMAL)
411            .build();
412        let q = opts.to_query();
413        assert_eq!(
414            get_q(&q, "shape").as_deref(),
415            Some(crate::SHAPE_CONTRACTS_MINIMAL)
416        );
417    }
418
419    #[tokio::test]
420    async fn get_contract_empty_key_returns_validation() {
421        let client = Client::builder().api_key("x").build().expect("build");
422        let err = client.get_contract("", None).await.expect_err("must error");
423        match err {
424            Error::Validation { message, .. } => assert!(message.contains("key")),
425            other => panic!("expected Validation, got {other:?}"),
426        }
427    }
428
429    #[tokio::test]
430    async fn list_contract_subawards_empty_key_returns_validation() {
431        let client = Client::builder().api_key("x").build().expect("build");
432        let err = client
433            .list_contract_subawards("", None)
434            .await
435            .expect_err("must error");
436        match err {
437            Error::Validation { message, .. } => assert!(message.contains("key")),
438            other => panic!("expected Validation, got {other:?}"),
439        }
440    }
441
442    #[tokio::test]
443    async fn list_contract_transactions_empty_key_returns_validation() {
444        let client = Client::builder().api_key("x").build().expect("build");
445        let err = client
446            .list_contract_transactions("", None)
447            .await
448            .expect_err("must error");
449        match err {
450            Error::Validation { message, .. } => assert!(message.contains("key")),
451            other => panic!("expected Validation, got {other:?}"),
452        }
453    }
454
455    #[test]
456    fn list_contracts_key_filter_emits() {
457        let q = ListContractsOptions::builder()
458            .key("K1|K2")
459            .build()
460            .to_query();
461        assert_eq!(get_q(&q, "key").as_deref(), Some("K1|K2"));
462    }
463
464    #[test]
465    fn list_contracts_commercial_item_acquisition_procedures_emits() {
466        let q = ListContractsOptions::builder()
467            .commercial_item_acquisition_procedures("A|E")
468            .build()
469            .to_query();
470        assert_eq!(
471            get_q(&q, "commercial_item_acquisition_procedures").as_deref(),
472            Some("A|E")
473        );
474    }
475}