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 self.get_page(&path, &q).await
245 }
246
247 pub async fn get_entity_metrics(
254 &self,
255 uei: &str,
256 months: u32,
257 period_grouping: &str,
258 ) -> Result<Record> {
259 if uei.is_empty() {
260 return Err(Error::Validation {
261 message: "get_entity_metrics: uei is required".into(),
262 response: None,
263 });
264 }
265 if months == 0 {
266 return Err(Error::Validation {
267 message: "get_entity_metrics: months must be > 0".into(),
268 response: None,
269 });
270 }
271 if period_grouping.is_empty() {
272 return Err(Error::Validation {
273 message: "get_entity_metrics: period_grouping is required".into(),
274 response: None,
275 });
276 }
277 let path = format!(
278 "/api/entities/{}/metrics/{}/{}/",
279 urlencoding(uei),
280 months,
281 urlencoding(period_grouping),
282 );
283 self.get_json::<Record>(&path, &[]).await
284 }
285}
286
287async fn list_entity_subresource(
288 client: &Client,
289 uei: &str,
290 segment: &str,
291 opts: EntitySubresourceOptions,
292) -> Result<Page<Record>> {
293 if uei.is_empty() {
294 return Err(Error::Validation {
295 message: "entity sub-resource: uei is required".into(),
296 response: None,
297 });
298 }
299 let q = opts.to_query();
300 let path = format!("/api/entities/{}/{segment}/", urlencoding(uei));
301 client.get_page(&path, &q).await
302}
303
304fn iterate_entity_subresource(
305 client: &Client,
306 uei: String,
307 segment: &'static str,
308 opts: EntitySubresourceOptions,
309) -> PageStream<Record> {
310 let opts = Arc::new(opts);
311 let uei = Arc::new(uei);
312 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
313 let mut next = (*opts).clone();
314 next.page = page;
315 next.cursor = cursor;
316 let uei = uei.clone();
317 Box::pin(async move { list_entity_subresource(&client, &uei, segment, next).await })
318 });
319 PageStream::new(client.clone(), fetch)
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
327 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
328 }
329
330 #[test]
331 fn options_emit_pagination_shape_and_search() {
332 let opts = EntitySubresourceOptions::builder()
333 .limit(10u32)
334 .shape("contracts(minimal)")
335 .ordering("-award_date")
336 .search("software")
337 .build();
338 let q = opts.to_query();
339 assert_eq!(get_q(&q, "limit").as_deref(), Some("10"));
340 assert_eq!(get_q(&q, "shape").as_deref(), Some("contracts(minimal)"));
341 assert_eq!(get_q(&q, "ordering").as_deref(), Some("-award_date"));
342 assert_eq!(get_q(&q, "search").as_deref(), Some("software"));
343 assert!(!q.iter().any(|(k, _)| k == "joiner"));
344 }
345
346 #[test]
347 fn joiner_only_when_flat() {
348 let opts = EntitySubresourceOptions::builder()
349 .joiner("__".to_string())
350 .build();
351 let q = opts.to_query();
352 assert!(!q.iter().any(|(k, _)| k == "joiner"));
353
354 let opts = EntitySubresourceOptions::builder()
355 .flat(true)
356 .joiner("__".to_string())
357 .build();
358 let q = opts.to_query();
359 assert!(q.contains(&("joiner".into(), "__".into())));
360 }
361
362 #[test]
363 fn extra_forwards_arbitrary_params() {
364 let mut extra = BTreeMap::new();
365 extra.insert("custom_x".to_string(), "val".to_string());
366 let opts = EntitySubresourceOptions::builder().extra(extra).build();
367 let q = opts.to_query();
368 assert!(q.contains(&("custom_x".into(), "val".into())));
369 }
370
371 #[tokio::test]
372 async fn list_entity_contracts_empty_uei_returns_validation() {
373 let client = Client::builder().api_key("x").build().expect("build");
374 let err = client
375 .list_entity_contracts("", EntitySubresourceOptions::default())
376 .await
377 .expect_err("must error");
378 match err {
379 Error::Validation { message, .. } => assert!(message.contains("uei")),
380 other => panic!("expected Validation, got {other:?}"),
381 }
382 }
383
384 #[tokio::test]
385 async fn list_entity_subawards_empty_uei_returns_validation() {
386 let client = Client::builder().api_key("x").build().expect("build");
387 let err = client
388 .list_entity_subawards("", EntitySubresourceOptions::default())
389 .await
390 .expect_err("must error");
391 match err {
392 Error::Validation { message, .. } => assert!(message.contains("uei")),
393 other => panic!("expected Validation, got {other:?}"),
394 }
395 }
396
397 #[test]
398 fn budget_flows_options_emit_fiscal_year() {
399 let q = EntityBudgetFlowsOptions::builder()
400 .fiscal_year(2024u32)
401 .limit(10u32)
402 .build()
403 .to_query();
404 assert_eq!(
405 q,
406 vec![
407 ("limit".to_string(), "10".to_string()),
408 ("fiscal_year".to_string(), "2024".to_string())
409 ]
410 );
411 }
412
413 #[tokio::test]
414 async fn get_entity_budget_flows_empty_uei_returns_validation() {
415 let client = Client::builder().api_key("x").build().expect("build");
416 let err = client
417 .get_entity_budget_flows("", None)
418 .await
419 .expect_err("must error");
420 match err {
421 Error::Validation { message, .. } => assert!(message.contains("uei")),
422 other => panic!("expected Validation, got {other:?}"),
423 }
424 }
425
426 #[tokio::test]
427 async fn get_entity_metrics_empty_uei_returns_validation() {
428 let client = Client::builder().api_key("x").build().expect("build");
429 let err = client
430 .get_entity_metrics("", 12, "month")
431 .await
432 .expect_err("must error");
433 match err {
434 Error::Validation { message, .. } => assert!(message.contains("uei")),
435 other => panic!("expected Validation, got {other:?}"),
436 }
437 }
438
439 #[tokio::test]
440 async fn get_entity_metrics_zero_months_returns_validation() {
441 let client = Client::builder().api_key("x").build().expect("build");
442 let err = client
443 .get_entity_metrics("ABC123DEF456", 0, "month")
444 .await
445 .expect_err("must error");
446 assert!(matches!(err, Error::Validation { .. }));
447 }
448
449 #[tokio::test]
450 async fn get_entity_metrics_empty_period_grouping_returns_validation() {
451 let client = Client::builder().api_key("x").build().expect("build");
452 let err = client
453 .get_entity_metrics("ABC123DEF456", 12, "")
454 .await
455 .expect_err("must error");
456 assert!(matches!(err, Error::Validation { .. }));
457 }
458}