1use crate::client::Client;
13use crate::error::{Error, Result};
14use crate::internal::{apply_pagination, push_opt};
15use crate::pagination::{FetchFn, Page, PageStream};
16use crate::resources::agencies::urlencoding;
17use crate::Record;
18use bon::Builder;
19use std::collections::BTreeMap;
20use std::sync::Arc;
21
22#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
24#[non_exhaustive]
25pub struct EntityBudgetFlowsOptions {
26 #[builder(into)]
28 pub page: Option<u32>,
29 #[builder(into)]
31 pub limit: Option<u32>,
32 pub fiscal_year: Option<u32>,
34}
35
36impl EntityBudgetFlowsOptions {
37 fn to_query(&self) -> Vec<(String, String)> {
38 let mut q = Vec::new();
39 apply_pagination(&mut q, self.page, self.limit, None, None, false, false);
40 if let Some(fy) = self.fiscal_year {
41 q.push(("fiscal_year".into(), fy.to_string()));
42 }
43 q
44 }
45}
46
47#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
51#[non_exhaustive]
52pub struct EntitySubresourceOptions {
53 #[builder(into)]
55 pub page: Option<u32>,
56 #[builder(into)]
58 pub limit: Option<u32>,
59 #[builder(into)]
61 pub cursor: Option<String>,
62 #[builder(into)]
64 pub shape: Option<String>,
65 #[builder(default)]
67 pub flat: bool,
68 #[builder(default)]
70 pub flat_lists: bool,
71 #[builder(into)]
73 pub joiner: Option<String>,
74 #[builder(into)]
78 pub ordering: Option<String>,
79 #[builder(into)]
81 pub search: Option<String>,
82 #[builder(default)]
84 pub extra: BTreeMap<String, String>,
85}
86
87impl EntitySubresourceOptions {
88 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
89 let mut q = Vec::new();
90 apply_pagination(
91 &mut q,
92 self.page,
93 self.limit,
94 self.cursor.as_deref(),
95 self.shape.as_deref(),
96 self.flat,
97 self.flat_lists,
98 );
99 if self.flat {
100 if let Some(j) = self.joiner.as_deref().filter(|s| !s.is_empty()) {
101 q.push(("joiner".into(), j.into()));
102 }
103 }
104 push_opt(&mut q, "ordering", self.ordering.as_deref());
105 push_opt(&mut q, "search", self.search.as_deref());
106 for (k, v) in &self.extra {
107 if !v.is_empty() {
108 q.push((k.clone(), v.clone()));
109 }
110 }
111 q
112 }
113}
114
115impl Client {
116 pub async fn list_entity_contracts(
118 &self,
119 uei: &str,
120 opts: EntitySubresourceOptions,
121 ) -> Result<Page<Record>> {
122 list_entity_subresource(self, uei, "contracts", opts).await
123 }
124
125 pub fn iterate_entity_contracts(
127 &self,
128 uei: &str,
129 opts: EntitySubresourceOptions,
130 ) -> PageStream<Record> {
131 iterate_entity_subresource(self, uei.to_string(), "contracts", opts)
132 }
133
134 pub async fn list_entity_idvs(
136 &self,
137 uei: &str,
138 opts: EntitySubresourceOptions,
139 ) -> Result<Page<Record>> {
140 list_entity_subresource(self, uei, "idvs", opts).await
141 }
142
143 pub fn iterate_entity_idvs(
145 &self,
146 uei: &str,
147 opts: EntitySubresourceOptions,
148 ) -> PageStream<Record> {
149 iterate_entity_subresource(self, uei.to_string(), "idvs", opts)
150 }
151
152 pub async fn list_entity_otas(
155 &self,
156 uei: &str,
157 opts: EntitySubresourceOptions,
158 ) -> Result<Page<Record>> {
159 list_entity_subresource(self, uei, "otas", opts).await
160 }
161
162 pub fn iterate_entity_otas(
164 &self,
165 uei: &str,
166 opts: EntitySubresourceOptions,
167 ) -> PageStream<Record> {
168 iterate_entity_subresource(self, uei.to_string(), "otas", opts)
169 }
170
171 pub async fn list_entity_otidvs(
174 &self,
175 uei: &str,
176 opts: EntitySubresourceOptions,
177 ) -> Result<Page<Record>> {
178 list_entity_subresource(self, uei, "otidvs", opts).await
179 }
180
181 pub fn iterate_entity_otidvs(
183 &self,
184 uei: &str,
185 opts: EntitySubresourceOptions,
186 ) -> PageStream<Record> {
187 iterate_entity_subresource(self, uei.to_string(), "otidvs", opts)
188 }
189
190 pub async fn list_entity_subawards(
193 &self,
194 uei: &str,
195 opts: EntitySubresourceOptions,
196 ) -> Result<Page<Record>> {
197 list_entity_subresource(self, uei, "subawards", opts).await
198 }
199
200 pub fn iterate_entity_subawards(
202 &self,
203 uei: &str,
204 opts: EntitySubresourceOptions,
205 ) -> PageStream<Record> {
206 iterate_entity_subresource(self, uei.to_string(), "subawards", opts)
207 }
208
209 pub async fn list_entity_lcats(
212 &self,
213 uei: &str,
214 opts: EntitySubresourceOptions,
215 ) -> Result<Page<Record>> {
216 list_entity_subresource(self, uei, "lcats", opts).await
217 }
218
219 pub fn iterate_entity_lcats(
221 &self,
222 uei: &str,
223 opts: EntitySubresourceOptions,
224 ) -> PageStream<Record> {
225 iterate_entity_subresource(self, uei.to_string(), "lcats", opts)
226 }
227
228 pub async fn get_entity_budget_flows(
232 &self,
233 uei: &str,
234 opts: Option<EntityBudgetFlowsOptions>,
235 ) -> Result<Page<Record>> {
236 if uei.is_empty() {
237 return Err(Error::Validation {
238 message: "get_entity_budget_flows: uei is required".into(),
239 response: None,
240 });
241 }
242 let q = opts.unwrap_or_default().to_query();
243 let path = format!("/api/entities/{}/budget-flows/", urlencoding(uei));
244 let bytes = self.get_bytes(&path, &q).await?;
245 Page::decode(&bytes)
246 }
247
248 pub async fn get_entity_metrics(
255 &self,
256 uei: &str,
257 months: u32,
258 period_grouping: &str,
259 ) -> Result<Record> {
260 if uei.is_empty() {
261 return Err(Error::Validation {
262 message: "get_entity_metrics: uei is required".into(),
263 response: None,
264 });
265 }
266 if months == 0 {
267 return Err(Error::Validation {
268 message: "get_entity_metrics: months must be > 0".into(),
269 response: None,
270 });
271 }
272 if period_grouping.is_empty() {
273 return Err(Error::Validation {
274 message: "get_entity_metrics: period_grouping is required".into(),
275 response: None,
276 });
277 }
278 let path = format!(
279 "/api/entities/{}/metrics/{}/{}/",
280 urlencoding(uei),
281 months,
282 urlencoding(period_grouping),
283 );
284 self.get_json::<Record>(&path, &[]).await
285 }
286}
287
288async fn list_entity_subresource(
289 client: &Client,
290 uei: &str,
291 segment: &str,
292 opts: EntitySubresourceOptions,
293) -> Result<Page<Record>> {
294 if uei.is_empty() {
295 return Err(Error::Validation {
296 message: "entity sub-resource: uei is required".into(),
297 response: None,
298 });
299 }
300 let q = opts.to_query();
301 let path = format!("/api/entities/{}/{segment}/", urlencoding(uei));
302 let bytes = client.get_bytes(&path, &q).await?;
303 Page::decode(&bytes)
304}
305
306fn iterate_entity_subresource(
307 client: &Client,
308 uei: String,
309 segment: &'static str,
310 opts: EntitySubresourceOptions,
311) -> PageStream<Record> {
312 let opts = Arc::new(opts);
313 let uei = Arc::new(uei);
314 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
315 let mut next = (*opts).clone();
316 next.page = page;
317 next.cursor = cursor;
318 let uei = uei.clone();
319 Box::pin(async move { list_entity_subresource(&client, &uei, segment, next).await })
320 });
321 PageStream::new(client.clone(), fetch)
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
329 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
330 }
331
332 #[test]
333 fn options_emit_pagination_shape_and_search() {
334 let opts = EntitySubresourceOptions::builder()
335 .limit(10u32)
336 .shape("contracts(minimal)")
337 .ordering("-award_date")
338 .search("software")
339 .build();
340 let q = opts.to_query();
341 assert_eq!(get_q(&q, "limit").as_deref(), Some("10"));
342 assert_eq!(get_q(&q, "shape").as_deref(), Some("contracts(minimal)"));
343 assert_eq!(get_q(&q, "ordering").as_deref(), Some("-award_date"));
344 assert_eq!(get_q(&q, "search").as_deref(), Some("software"));
345 assert!(!q.iter().any(|(k, _)| k == "joiner"));
346 }
347
348 #[test]
349 fn joiner_only_when_flat() {
350 let opts = EntitySubresourceOptions::builder()
351 .joiner("__".to_string())
352 .build();
353 let q = opts.to_query();
354 assert!(!q.iter().any(|(k, _)| k == "joiner"));
355
356 let opts = EntitySubresourceOptions::builder()
357 .flat(true)
358 .joiner("__".to_string())
359 .build();
360 let q = opts.to_query();
361 assert!(q.contains(&("joiner".into(), "__".into())));
362 }
363
364 #[test]
365 fn extra_forwards_arbitrary_params() {
366 let mut extra = BTreeMap::new();
367 extra.insert("custom_x".to_string(), "val".to_string());
368 let opts = EntitySubresourceOptions::builder().extra(extra).build();
369 let q = opts.to_query();
370 assert!(q.contains(&("custom_x".into(), "val".into())));
371 }
372
373 #[tokio::test]
374 async fn list_entity_contracts_empty_uei_returns_validation() {
375 let client = Client::builder().api_key("x").build().expect("build");
376 let err = client
377 .list_entity_contracts("", EntitySubresourceOptions::default())
378 .await
379 .expect_err("must error");
380 match err {
381 Error::Validation { message, .. } => assert!(message.contains("uei")),
382 other => panic!("expected Validation, got {other:?}"),
383 }
384 }
385
386 #[tokio::test]
387 async fn list_entity_subawards_empty_uei_returns_validation() {
388 let client = Client::builder().api_key("x").build().expect("build");
389 let err = client
390 .list_entity_subawards("", EntitySubresourceOptions::default())
391 .await
392 .expect_err("must error");
393 match err {
394 Error::Validation { message, .. } => assert!(message.contains("uei")),
395 other => panic!("expected Validation, got {other:?}"),
396 }
397 }
398
399 #[test]
400 fn budget_flows_options_emit_fiscal_year() {
401 let q = EntityBudgetFlowsOptions::builder()
402 .fiscal_year(2024u32)
403 .limit(10u32)
404 .build()
405 .to_query();
406 assert_eq!(
407 q,
408 vec![
409 ("limit".to_string(), "10".to_string()),
410 ("fiscal_year".to_string(), "2024".to_string())
411 ]
412 );
413 }
414
415 #[tokio::test]
416 async fn get_entity_budget_flows_empty_uei_returns_validation() {
417 let client = Client::builder().api_key("x").build().expect("build");
418 let err = client
419 .get_entity_budget_flows("", None)
420 .await
421 .expect_err("must error");
422 match err {
423 Error::Validation { message, .. } => assert!(message.contains("uei")),
424 other => panic!("expected Validation, got {other:?}"),
425 }
426 }
427
428 #[tokio::test]
429 async fn get_entity_metrics_empty_uei_returns_validation() {
430 let client = Client::builder().api_key("x").build().expect("build");
431 let err = client
432 .get_entity_metrics("", 12, "month")
433 .await
434 .expect_err("must error");
435 match err {
436 Error::Validation { message, .. } => assert!(message.contains("uei")),
437 other => panic!("expected Validation, got {other:?}"),
438 }
439 }
440
441 #[tokio::test]
442 async fn get_entity_metrics_zero_months_returns_validation() {
443 let client = Client::builder().api_key("x").build().expect("build");
444 let err = client
445 .get_entity_metrics("ABC123DEF456", 0, "month")
446 .await
447 .expect_err("must error");
448 assert!(matches!(err, Error::Validation { .. }));
449 }
450
451 #[tokio::test]
452 async fn get_entity_metrics_empty_period_grouping_returns_validation() {
453 let client = Client::builder().api_key("x").build().expect("build");
454 let err = client
455 .get_entity_metrics("ABC123DEF456", 12, "")
456 .await
457 .expect_err("must error");
458 assert!(matches!(err, Error::Validation { .. }));
459 }
460}