1use 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#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
19#[non_exhaustive]
20pub struct ListSubawardsOptions {
21 #[builder(into)]
23 pub page: Option<u32>,
24 #[builder(into)]
26 pub limit: Option<u32>,
27 #[builder(into)]
29 pub cursor: Option<String>,
30 #[builder(into)]
32 pub shape: Option<String>,
33 #[builder(default)]
35 pub flat: bool,
36 #[builder(default)]
38 pub flat_lists: bool,
39
40 #[builder(into)]
42 pub award_key: Option<String>,
43 #[builder(into)]
45 pub prime_uei: Option<String>,
46 #[builder(into)]
48 pub sub_uei: Option<String>,
49 #[builder(into)]
51 pub awarding_agency: Option<String>,
52 #[builder(into)]
54 pub funding_agency: Option<String>,
55 #[builder(into)]
57 pub fiscal_year: Option<String>,
58 #[builder(into)]
60 pub fiscal_year_gte: Option<String>,
61 #[builder(into)]
63 pub fiscal_year_lte: Option<String>,
64 #[builder(into)]
66 pub recipient: Option<String>,
67 #[builder(into)]
70 pub ordering: Option<String>,
71
72 #[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 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 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 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}