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