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        self.get_page("/api/subawards/", &q).await
113    }
114
115    /// `GET /api/subawards/{key}/` — a single subaward record.
116    pub async fn get_subaward(&self, key: &str, opts: Option<ListOptions>) -> Result<Record> {
117        if key.is_empty() {
118            return Err(Error::Validation {
119                message: "get_subaward: key is required".into(),
120                response: None,
121            });
122        }
123        let mut q = Vec::new();
124        opts.unwrap_or_default().apply(&mut q);
125        let path = format!("/api/subawards/{}/", urlencoding(key));
126        self.get_json::<Record>(&path, &q).await
127    }
128
129    /// Stream every subaward matching `opts`.
130    pub fn iterate_subawards(&self, opts: ListSubawardsOptions) -> PageStream<Record> {
131        let opts = Arc::new(opts);
132        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
133            let mut next = (*opts).clone();
134            next.page = page;
135            next.cursor = cursor;
136            Box::pin(async move { client.list_subawards(next).await })
137        });
138        PageStream::new(self.clone(), fetch)
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
147        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
148    }
149
150    #[test]
151    fn subawards_emits_all_filters() {
152        let opts = ListSubawardsOptions::builder()
153            .award_key("PIID-123")
154            .prime_uei("UEI-PRIME")
155            .sub_uei("UEI-SUB")
156            .awarding_agency("9700")
157            .funding_agency("9700")
158            .fiscal_year("2024")
159            .fiscal_year_gte("2023")
160            .fiscal_year_lte("2025")
161            .recipient("Acme Inc")
162            .ordering("-last_modified_date")
163            .build();
164        let q = opts.to_query();
165        assert_eq!(get_q(&q, "award_key").as_deref(), Some("PIID-123"));
166        assert_eq!(get_q(&q, "prime_uei").as_deref(), Some("UEI-PRIME"));
167        assert_eq!(get_q(&q, "sub_uei").as_deref(), Some("UEI-SUB"));
168        assert_eq!(get_q(&q, "awarding_agency").as_deref(), Some("9700"));
169        assert_eq!(get_q(&q, "funding_agency").as_deref(), Some("9700"));
170        assert_eq!(get_q(&q, "fiscal_year").as_deref(), Some("2024"));
171        assert_eq!(get_q(&q, "fiscal_year_gte").as_deref(), Some("2023"));
172        assert_eq!(get_q(&q, "fiscal_year_lte").as_deref(), Some("2025"));
173        assert_eq!(get_q(&q, "recipient").as_deref(), Some("Acme Inc"));
174        assert_eq!(
175            get_q(&q, "ordering").as_deref(),
176            Some("-last_modified_date")
177        );
178    }
179
180    #[test]
181    fn subawards_pagination_emits() {
182        let opts = ListSubawardsOptions::builder()
183            .page(2u32)
184            .limit(50u32)
185            .shape(crate::SHAPE_SUBAWARDS_MINIMAL)
186            .build();
187        let q = opts.to_query();
188        assert_eq!(get_q(&q, "page").as_deref(), Some("2"));
189        assert_eq!(get_q(&q, "limit").as_deref(), Some("50"));
190        assert_eq!(
191            get_q(&q, "shape").as_deref(),
192            Some(crate::SHAPE_SUBAWARDS_MINIMAL)
193        );
194    }
195
196    #[test]
197    fn subawards_extra_map() {
198        let mut extra = BTreeMap::new();
199        extra.insert("x".to_string(), "y".to_string());
200        let opts = ListSubawardsOptions::builder().extra(extra).build();
201        let q = opts.to_query();
202        assert_eq!(get_q(&q, "x").as_deref(), Some("y"));
203    }
204
205    #[tokio::test]
206    async fn get_subaward_empty_key_returns_validation() {
207        let client = Client::builder().api_key("x").build().expect("build");
208        let err = client.get_subaward("", None).await.expect_err("must error");
209        match err {
210            Error::Validation { message, .. } => assert!(message.contains("key")),
211            other => panic!("expected Validation, got {other:?}"),
212        }
213    }
214}