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 self.get_page("/api/subawards/", &q).await
113 }
114
115 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 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}