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)]
26#[non_exhaustive]
27pub struct EntitySubresourceOptions {
28 #[builder(into)]
30 pub page: Option<u32>,
31 #[builder(into)]
33 pub limit: Option<u32>,
34 #[builder(into)]
36 pub cursor: Option<String>,
37 #[builder(into)]
39 pub shape: Option<String>,
40 #[builder(default)]
42 pub flat: bool,
43 #[builder(default)]
45 pub flat_lists: bool,
46 #[builder(into)]
48 pub joiner: Option<String>,
49 #[builder(into)]
53 pub ordering: Option<String>,
54 #[builder(into)]
56 pub search: Option<String>,
57 #[builder(default)]
59 pub extra: BTreeMap<String, String>,
60}
61
62impl EntitySubresourceOptions {
63 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
64 let mut q = Vec::new();
65 apply_pagination(
66 &mut q,
67 self.page,
68 self.limit,
69 self.cursor.as_deref(),
70 self.shape.as_deref(),
71 self.flat,
72 self.flat_lists,
73 );
74 if self.flat {
75 if let Some(j) = self.joiner.as_deref().filter(|s| !s.is_empty()) {
76 q.push(("joiner".into(), j.into()));
77 }
78 }
79 push_opt(&mut q, "ordering", self.ordering.as_deref());
80 push_opt(&mut q, "search", self.search.as_deref());
81 for (k, v) in &self.extra {
82 if !v.is_empty() {
83 q.push((k.clone(), v.clone()));
84 }
85 }
86 q
87 }
88}
89
90impl Client {
91 pub async fn list_entity_contracts(
93 &self,
94 uei: &str,
95 opts: EntitySubresourceOptions,
96 ) -> Result<Page<Record>> {
97 list_entity_subresource(self, uei, "contracts", opts).await
98 }
99
100 pub fn iterate_entity_contracts(
102 &self,
103 uei: &str,
104 opts: EntitySubresourceOptions,
105 ) -> PageStream<Record> {
106 iterate_entity_subresource(self, uei.to_string(), "contracts", opts)
107 }
108
109 pub async fn list_entity_idvs(
111 &self,
112 uei: &str,
113 opts: EntitySubresourceOptions,
114 ) -> Result<Page<Record>> {
115 list_entity_subresource(self, uei, "idvs", opts).await
116 }
117
118 pub fn iterate_entity_idvs(
120 &self,
121 uei: &str,
122 opts: EntitySubresourceOptions,
123 ) -> PageStream<Record> {
124 iterate_entity_subresource(self, uei.to_string(), "idvs", opts)
125 }
126
127 pub async fn list_entity_otas(
130 &self,
131 uei: &str,
132 opts: EntitySubresourceOptions,
133 ) -> Result<Page<Record>> {
134 list_entity_subresource(self, uei, "otas", opts).await
135 }
136
137 pub fn iterate_entity_otas(
139 &self,
140 uei: &str,
141 opts: EntitySubresourceOptions,
142 ) -> PageStream<Record> {
143 iterate_entity_subresource(self, uei.to_string(), "otas", opts)
144 }
145
146 pub async fn list_entity_otidvs(
149 &self,
150 uei: &str,
151 opts: EntitySubresourceOptions,
152 ) -> Result<Page<Record>> {
153 list_entity_subresource(self, uei, "otidvs", opts).await
154 }
155
156 pub fn iterate_entity_otidvs(
158 &self,
159 uei: &str,
160 opts: EntitySubresourceOptions,
161 ) -> PageStream<Record> {
162 iterate_entity_subresource(self, uei.to_string(), "otidvs", opts)
163 }
164
165 pub async fn list_entity_subawards(
168 &self,
169 uei: &str,
170 opts: EntitySubresourceOptions,
171 ) -> Result<Page<Record>> {
172 list_entity_subresource(self, uei, "subawards", opts).await
173 }
174
175 pub fn iterate_entity_subawards(
177 &self,
178 uei: &str,
179 opts: EntitySubresourceOptions,
180 ) -> PageStream<Record> {
181 iterate_entity_subresource(self, uei.to_string(), "subawards", opts)
182 }
183
184 pub async fn list_entity_lcats(
187 &self,
188 uei: &str,
189 opts: EntitySubresourceOptions,
190 ) -> Result<Page<Record>> {
191 list_entity_subresource(self, uei, "lcats", opts).await
192 }
193
194 pub fn iterate_entity_lcats(
196 &self,
197 uei: &str,
198 opts: EntitySubresourceOptions,
199 ) -> PageStream<Record> {
200 iterate_entity_subresource(self, uei.to_string(), "lcats", opts)
201 }
202
203 pub async fn get_entity_metrics(
210 &self,
211 uei: &str,
212 months: u32,
213 period_grouping: &str,
214 ) -> Result<Record> {
215 if uei.is_empty() {
216 return Err(Error::Validation {
217 message: "get_entity_metrics: uei is required".into(),
218 response: None,
219 });
220 }
221 if months == 0 {
222 return Err(Error::Validation {
223 message: "get_entity_metrics: months must be > 0".into(),
224 response: None,
225 });
226 }
227 if period_grouping.is_empty() {
228 return Err(Error::Validation {
229 message: "get_entity_metrics: period_grouping is required".into(),
230 response: None,
231 });
232 }
233 let path = format!(
234 "/api/entities/{}/metrics/{}/{}/",
235 urlencoding(uei),
236 months,
237 urlencoding(period_grouping),
238 );
239 self.get_json::<Record>(&path, &[]).await
240 }
241}
242
243async fn list_entity_subresource(
244 client: &Client,
245 uei: &str,
246 segment: &str,
247 opts: EntitySubresourceOptions,
248) -> Result<Page<Record>> {
249 if uei.is_empty() {
250 return Err(Error::Validation {
251 message: "entity sub-resource: uei is required".into(),
252 response: None,
253 });
254 }
255 let q = opts.to_query();
256 let path = format!("/api/entities/{}/{segment}/", urlencoding(uei));
257 let bytes = client.get_bytes(&path, &q).await?;
258 Page::decode(&bytes)
259}
260
261fn iterate_entity_subresource(
262 client: &Client,
263 uei: String,
264 segment: &'static str,
265 opts: EntitySubresourceOptions,
266) -> PageStream<Record> {
267 let opts = Arc::new(opts);
268 let uei = Arc::new(uei);
269 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
270 let mut next = (*opts).clone();
271 next.page = page;
272 next.cursor = cursor;
273 let uei = uei.clone();
274 Box::pin(async move { list_entity_subresource(&client, &uei, segment, next).await })
275 });
276 PageStream::new(client.clone(), fetch)
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
284 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
285 }
286
287 #[test]
288 fn options_emit_pagination_shape_and_search() {
289 let opts = EntitySubresourceOptions::builder()
290 .limit(10u32)
291 .shape("contracts(minimal)")
292 .ordering("-award_date")
293 .search("software")
294 .build();
295 let q = opts.to_query();
296 assert_eq!(get_q(&q, "limit").as_deref(), Some("10"));
297 assert_eq!(get_q(&q, "shape").as_deref(), Some("contracts(minimal)"));
298 assert_eq!(get_q(&q, "ordering").as_deref(), Some("-award_date"));
299 assert_eq!(get_q(&q, "search").as_deref(), Some("software"));
300 assert!(!q.iter().any(|(k, _)| k == "joiner"));
301 }
302
303 #[test]
304 fn joiner_only_when_flat() {
305 let opts = EntitySubresourceOptions::builder()
306 .joiner("__".to_string())
307 .build();
308 let q = opts.to_query();
309 assert!(!q.iter().any(|(k, _)| k == "joiner"));
310
311 let opts = EntitySubresourceOptions::builder()
312 .flat(true)
313 .joiner("__".to_string())
314 .build();
315 let q = opts.to_query();
316 assert!(q.contains(&("joiner".into(), "__".into())));
317 }
318
319 #[test]
320 fn extra_forwards_arbitrary_params() {
321 let mut extra = BTreeMap::new();
322 extra.insert("custom_x".to_string(), "val".to_string());
323 let opts = EntitySubresourceOptions::builder().extra(extra).build();
324 let q = opts.to_query();
325 assert!(q.contains(&("custom_x".into(), "val".into())));
326 }
327
328 #[tokio::test]
329 async fn list_entity_contracts_empty_uei_returns_validation() {
330 let client = Client::builder().api_key("x").build().expect("build");
331 let err = client
332 .list_entity_contracts("", EntitySubresourceOptions::default())
333 .await
334 .expect_err("must error");
335 match err {
336 Error::Validation { message, .. } => assert!(message.contains("uei")),
337 other => panic!("expected Validation, got {other:?}"),
338 }
339 }
340
341 #[tokio::test]
342 async fn list_entity_subawards_empty_uei_returns_validation() {
343 let client = Client::builder().api_key("x").build().expect("build");
344 let err = client
345 .list_entity_subawards("", EntitySubresourceOptions::default())
346 .await
347 .expect_err("must error");
348 match err {
349 Error::Validation { message, .. } => assert!(message.contains("uei")),
350 other => panic!("expected Validation, got {other:?}"),
351 }
352 }
353
354 #[tokio::test]
355 async fn get_entity_metrics_empty_uei_returns_validation() {
356 let client = Client::builder().api_key("x").build().expect("build");
357 let err = client
358 .get_entity_metrics("", 12, "month")
359 .await
360 .expect_err("must error");
361 match err {
362 Error::Validation { message, .. } => assert!(message.contains("uei")),
363 other => panic!("expected Validation, got {other:?}"),
364 }
365 }
366
367 #[tokio::test]
368 async fn get_entity_metrics_zero_months_returns_validation() {
369 let client = Client::builder().api_key("x").build().expect("build");
370 let err = client
371 .get_entity_metrics("ABC123DEF456", 0, "month")
372 .await
373 .expect_err("must error");
374 assert!(matches!(err, Error::Validation { .. }));
375 }
376
377 #[tokio::test]
378 async fn get_entity_metrics_empty_period_grouping_returns_validation() {
379 let client = Client::builder().api_key("x").build().expect("build");
380 let err = client
381 .get_entity_metrics("ABC123DEF456", 12, "")
382 .await
383 .expect_err("must error");
384 assert!(matches!(err, Error::Validation { .. }));
385 }
386}