Skip to main content

tango/resources/
subawards.rs

1//! `GET /api/subawards/` — list and stream subaward records.
2
3use crate::client::Client;
4use crate::error::Result;
5use crate::internal::{apply_pagination, 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_subawards`] and [`Client::iterate_subawards`].
13///
14/// Mirrors `ListSubawardsOptions` in the Go SDK. `ordering` must be
15/// `"last_modified_date"` or `"-last_modified_date"`; the server rejects
16/// other values (tango#2254).
17#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
18#[non_exhaustive]
19pub struct ListSubawardsOptions {
20    /// 1-based page number.
21    #[builder(into)]
22    pub page: Option<u32>,
23    /// Page size.
24    #[builder(into)]
25    pub limit: Option<u32>,
26    /// Keyset cursor.
27    #[builder(into)]
28    pub cursor: Option<String>,
29    /// Comma-separated field selector.
30    #[builder(into)]
31    pub shape: Option<String>,
32    /// Collapse nested objects into dot-separated keys.
33    #[builder(default)]
34    pub flat: bool,
35    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
36    #[builder(default)]
37    pub flat_lists: bool,
38
39    /// Award key filter (typically the prime PIID).
40    #[builder(into)]
41    pub award_key: Option<String>,
42    /// Prime recipient UEI filter.
43    #[builder(into)]
44    pub prime_uei: Option<String>,
45    /// Subrecipient UEI filter.
46    #[builder(into)]
47    pub sub_uei: Option<String>,
48    /// Awarding agency CGAC code.
49    #[builder(into)]
50    pub awarding_agency: Option<String>,
51    /// Funding agency CGAC code.
52    #[builder(into)]
53    pub funding_agency: Option<String>,
54    /// `fiscal_year` filter.
55    #[builder(into)]
56    pub fiscal_year: Option<String>,
57    /// Lower bound for `fiscal_year`.
58    #[builder(into)]
59    pub fiscal_year_gte: Option<String>,
60    /// Upper bound for `fiscal_year`.
61    #[builder(into)]
62    pub fiscal_year_lte: Option<String>,
63    /// Recipient (sub or prime depending on endpoint allowlist).
64    #[builder(into)]
65    pub recipient: Option<String>,
66    /// Server-side sort spec. Must be `"last_modified_date"` or
67    /// `"-last_modified_date"`.
68    #[builder(into)]
69    pub ordering: Option<String>,
70
71    /// Escape hatch for filter keys not yet first-classed on this struct.
72    #[builder(default)]
73    pub extra: BTreeMap<String, String>,
74}
75
76impl ListSubawardsOptions {
77    fn to_query(&self) -> Vec<(String, String)> {
78        let mut q = Vec::new();
79        apply_pagination(
80            &mut q,
81            self.page,
82            self.limit,
83            self.cursor.as_deref(),
84            self.shape.as_deref(),
85            self.flat,
86            self.flat_lists,
87        );
88        push_opt(&mut q, "award_key", self.award_key.as_deref());
89        push_opt(&mut q, "prime_uei", self.prime_uei.as_deref());
90        push_opt(&mut q, "sub_uei", self.sub_uei.as_deref());
91        push_opt(&mut q, "awarding_agency", self.awarding_agency.as_deref());
92        push_opt(&mut q, "funding_agency", self.funding_agency.as_deref());
93        push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
94        push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
95        push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
96        push_opt(&mut q, "recipient", self.recipient.as_deref());
97        push_opt(&mut q, "ordering", self.ordering.as_deref());
98        for (k, v) in &self.extra {
99            if !v.is_empty() {
100                q.push((k.clone(), v.clone()));
101            }
102        }
103        q
104    }
105}
106
107impl Client {
108    /// `GET /api/subawards/` — one page of subaward records.
109    pub async fn list_subawards(&self, opts: ListSubawardsOptions) -> Result<Page<Record>> {
110        let q = opts.to_query();
111        let bytes = self.get_bytes("/api/subawards/", &q).await?;
112        Page::decode(&bytes)
113    }
114
115    /// Stream every subaward matching `opts`.
116    pub fn iterate_subawards(&self, opts: ListSubawardsOptions) -> PageStream<Record> {
117        let opts = Arc::new(opts);
118        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
119            let mut next = (*opts).clone();
120            next.page = page;
121            next.cursor = cursor;
122            Box::pin(async move { client.list_subawards(next).await })
123        });
124        PageStream::new(self.clone(), fetch)
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
133        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
134    }
135
136    #[test]
137    fn subawards_emits_all_filters() {
138        let opts = ListSubawardsOptions::builder()
139            .award_key("PIID-123")
140            .prime_uei("UEI-PRIME")
141            .sub_uei("UEI-SUB")
142            .awarding_agency("9700")
143            .funding_agency("9700")
144            .fiscal_year("2024")
145            .fiscal_year_gte("2023")
146            .fiscal_year_lte("2025")
147            .recipient("Acme Inc")
148            .ordering("-last_modified_date")
149            .build();
150        let q = opts.to_query();
151        assert_eq!(get_q(&q, "award_key").as_deref(), Some("PIID-123"));
152        assert_eq!(get_q(&q, "prime_uei").as_deref(), Some("UEI-PRIME"));
153        assert_eq!(get_q(&q, "sub_uei").as_deref(), Some("UEI-SUB"));
154        assert_eq!(get_q(&q, "awarding_agency").as_deref(), Some("9700"));
155        assert_eq!(get_q(&q, "funding_agency").as_deref(), Some("9700"));
156        assert_eq!(get_q(&q, "fiscal_year").as_deref(), Some("2024"));
157        assert_eq!(get_q(&q, "fiscal_year_gte").as_deref(), Some("2023"));
158        assert_eq!(get_q(&q, "fiscal_year_lte").as_deref(), Some("2025"));
159        assert_eq!(get_q(&q, "recipient").as_deref(), Some("Acme Inc"));
160        assert_eq!(
161            get_q(&q, "ordering").as_deref(),
162            Some("-last_modified_date")
163        );
164    }
165
166    #[test]
167    fn subawards_pagination_emits() {
168        let opts = ListSubawardsOptions::builder()
169            .page(2u32)
170            .limit(50u32)
171            .shape(crate::SHAPE_SUBAWARDS_MINIMAL)
172            .build();
173        let q = opts.to_query();
174        assert_eq!(get_q(&q, "page").as_deref(), Some("2"));
175        assert_eq!(get_q(&q, "limit").as_deref(), Some("50"));
176        assert_eq!(
177            get_q(&q, "shape").as_deref(),
178            Some(crate::SHAPE_SUBAWARDS_MINIMAL)
179        );
180    }
181
182    #[test]
183    fn subawards_extra_map() {
184        let mut extra = BTreeMap::new();
185        extra.insert("x".to_string(), "y".to_string());
186        let opts = ListSubawardsOptions::builder().extra(extra).build();
187        let q = opts.to_query();
188        assert_eq!(get_q(&q, "x").as_deref(), Some("y"));
189    }
190}