1use crate::client::Client;
4use crate::error::{Error, Result};
5use crate::internal::{apply_pagination, first_non_empty, push_opt, ListOptions};
6use crate::pagination::{FetchFn, Page, PageStream};
7use crate::resources::agencies::urlencoding;
8use crate::resources::entity_subresources::EntitySubresourceOptions;
9use crate::Record;
10use bon::Builder;
11use std::collections::BTreeMap;
12use std::sync::Arc;
13
14#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
20#[non_exhaustive]
21pub struct ListContractsOptions {
22 #[builder(into)]
25 pub page: Option<u32>,
26 #[builder(into)]
28 pub limit: Option<u32>,
29 #[builder(into)]
32 pub cursor: Option<String>,
33 #[builder(into)]
36 pub shape: Option<String>,
37 #[builder(default)]
39 pub flat: bool,
40 #[builder(default)]
42 pub flat_lists: bool,
43
44 #[builder(into)]
47 pub award_date: Option<String>,
48 #[builder(into)]
50 pub award_date_gte: Option<String>,
51 #[builder(into)]
53 pub award_date_lte: Option<String>,
54 #[builder(into)]
56 pub award_type: Option<String>,
57
58 #[builder(into)]
60 pub fiscal_year: Option<String>,
61 #[builder(into)]
63 pub fiscal_year_gte: Option<String>,
64 #[builder(into)]
66 pub fiscal_year_lte: Option<String>,
67
68 #[builder(into)]
70 pub obligated_gte: Option<String>,
71 #[builder(into)]
73 pub obligated_lte: Option<String>,
74
75 #[builder(into)]
77 pub pop_start_date_gte: Option<String>,
78 #[builder(into)]
80 pub pop_start_date_lte: Option<String>,
81 #[builder(into)]
83 pub pop_end_date_gte: Option<String>,
84 #[builder(into)]
86 pub pop_end_date_lte: Option<String>,
87 #[builder(into)]
89 pub expiring_gte: Option<String>,
90 #[builder(into)]
92 pub expiring_lte: Option<String>,
93
94 #[builder(into)]
96 pub awarding_agency: Option<String>,
97 #[builder(into)]
99 pub funding_agency: Option<String>,
100 #[builder(into)]
102 pub piid: Option<String>,
103 #[builder(into)]
105 pub solicitation_identifier: Option<String>,
106 #[builder(into)]
108 pub naics: Option<String>,
109 #[builder(into)]
111 pub psc: Option<String>,
112 #[builder(into)]
114 pub recipient: Option<String>,
115 #[builder(into)]
117 pub uei: Option<String>,
118 #[builder(into)]
120 pub set_aside: Option<String>,
121
122 #[builder(into)]
125 pub naics_code: Option<String>,
126 #[builder(into)]
128 pub psc_code: Option<String>,
129 #[builder(into)]
131 pub recipient_name: Option<String>,
132 #[builder(into)]
134 pub recipient_uei: Option<String>,
135 #[builder(into)]
137 pub set_aside_type: Option<String>,
138
139 #[builder(into)]
141 pub search: Option<String>,
142 #[builder(into)]
144 pub keyword: Option<String>,
145 #[builder(into)]
147 pub ordering: Option<String>,
148 #[builder(into)]
150 pub sort: Option<String>,
151 #[builder(into)]
153 pub order: Option<String>,
154
155 #[builder(into)]
157 pub key: Option<String>,
158
159 #[builder(default)]
161 pub extra: BTreeMap<String, String>,
162}
163
164impl ListContractsOptions {
165 fn to_query(&self) -> Vec<(String, String)> {
166 let mut q = Vec::new();
167 apply_pagination(
168 &mut q,
169 self.page,
170 self.limit,
171 self.cursor.as_deref(),
172 self.shape.as_deref(),
173 self.flat,
174 self.flat_lists,
175 );
176
177 push_opt(&mut q, "award_date", self.award_date.as_deref());
178 push_opt(&mut q, "award_date_gte", self.award_date_gte.as_deref());
179 push_opt(&mut q, "award_date_lte", self.award_date_lte.as_deref());
180 push_opt(&mut q, "award_type", self.award_type.as_deref());
181 push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
182 push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
183 push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
184 push_opt(&mut q, "obligated_gte", self.obligated_gte.as_deref());
185 push_opt(&mut q, "obligated_lte", self.obligated_lte.as_deref());
186 push_opt(
187 &mut q,
188 "pop_start_date_gte",
189 self.pop_start_date_gte.as_deref(),
190 );
191 push_opt(
192 &mut q,
193 "pop_start_date_lte",
194 self.pop_start_date_lte.as_deref(),
195 );
196 push_opt(&mut q, "pop_end_date_gte", self.pop_end_date_gte.as_deref());
197 push_opt(&mut q, "pop_end_date_lte", self.pop_end_date_lte.as_deref());
198 push_opt(&mut q, "expiring_gte", self.expiring_gte.as_deref());
199 push_opt(&mut q, "expiring_lte", self.expiring_lte.as_deref());
200 push_opt(&mut q, "awarding_agency", self.awarding_agency.as_deref());
201 push_opt(&mut q, "funding_agency", self.funding_agency.as_deref());
202 push_opt(&mut q, "piid", self.piid.as_deref());
203 push_opt(&mut q, "key", self.key.as_deref());
204 push_opt(
205 &mut q,
206 "solicitation_identifier",
207 self.solicitation_identifier.as_deref(),
208 );
209
210 push_opt(
212 &mut q,
213 "naics",
214 first_non_empty(&[self.naics_code.as_deref(), self.naics.as_deref()]),
215 );
216 push_opt(
217 &mut q,
218 "psc",
219 first_non_empty(&[self.psc_code.as_deref(), self.psc.as_deref()]),
220 );
221 push_opt(
222 &mut q,
223 "recipient",
224 first_non_empty(&[self.recipient_name.as_deref(), self.recipient.as_deref()]),
225 );
226 push_opt(
227 &mut q,
228 "uei",
229 first_non_empty(&[self.recipient_uei.as_deref(), self.uei.as_deref()]),
230 );
231 push_opt(
232 &mut q,
233 "set_aside",
234 first_non_empty(&[self.set_aside_type.as_deref(), self.set_aside.as_deref()]),
235 );
236 push_opt(
237 &mut q,
238 "search",
239 first_non_empty(&[self.keyword.as_deref(), self.search.as_deref()]),
240 );
241
242 if let Some(sort) = self.sort.as_deref().filter(|s| !s.is_empty()) {
243 let order = self.order.as_deref().unwrap_or("");
244 let prefix = if order == "desc" { "-" } else { "" };
245 q.push(("ordering".into(), format!("{prefix}{sort}")));
246 } else {
247 push_opt(&mut q, "ordering", self.ordering.as_deref());
248 }
249
250 for (k, v) in &self.extra {
251 if !v.is_empty() {
252 q.push((k.clone(), v.clone()));
253 }
254 }
255 q
256 }
257}
258
259impl Client {
260 pub async fn list_contracts(&self, opts: ListContractsOptions) -> Result<Page<Record>> {
262 let q = opts.to_query();
263 let bytes = self.get_bytes("/api/contracts/", &q).await?;
264 Page::decode(&bytes)
265 }
266
267 pub async fn get_contract(&self, key: &str, opts: Option<ListOptions>) -> Result<Record> {
269 if key.is_empty() {
270 return Err(Error::Validation {
271 message: "get_contract: key is required".into(),
272 response: None,
273 });
274 }
275 let mut q = Vec::new();
276 opts.unwrap_or_default().apply(&mut q);
277 let path = format!("/api/contracts/{}/", urlencoding(key));
278 self.get_json::<Record>(&path, &q).await
279 }
280
281 pub async fn list_contract_subawards(
284 &self,
285 key: &str,
286 opts: Option<EntitySubresourceOptions>,
287 ) -> Result<Page<Record>> {
288 if key.is_empty() {
289 return Err(Error::Validation {
290 message: "list_contract_subawards: key is required".into(),
291 response: None,
292 });
293 }
294 let q = opts.unwrap_or_default().to_query();
295 let path = format!("/api/contracts/{}/subawards/", urlencoding(key));
296 let bytes = self.get_bytes(&path, &q).await?;
297 Page::decode(&bytes)
298 }
299
300 pub async fn list_contract_transactions(
303 &self,
304 key: &str,
305 opts: Option<EntitySubresourceOptions>,
306 ) -> Result<Page<Record>> {
307 if key.is_empty() {
308 return Err(Error::Validation {
309 message: "list_contract_transactions: key is required".into(),
310 response: None,
311 });
312 }
313 let q = opts.unwrap_or_default().to_query();
314 let path = format!("/api/contracts/{}/transactions/", urlencoding(key));
315 let bytes = self.get_bytes(&path, &q).await?;
316 Page::decode(&bytes)
317 }
318
319 pub fn iterate_contracts(&self, opts: ListContractsOptions) -> PageStream<Record> {
335 let opts = Arc::new(opts);
336 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
337 let mut next = (*opts).clone();
338 next.page = page;
339 next.cursor = cursor;
340 Box::pin(async move { client.list_contracts(next).await })
341 });
342 PageStream::new(self.clone(), fetch)
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
351 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
352 }
353
354 #[test]
355 fn alias_prefers_sdk_name_over_canonical() {
356 let opts = ListContractsOptions::builder()
357 .naics_code("541512")
358 .naics("999999")
359 .build();
360 let q = opts.to_query();
361 assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
362 }
363
364 #[test]
365 fn alias_falls_through_to_canonical() {
366 let opts = ListContractsOptions::builder().naics("541512").build();
367 let q = opts.to_query();
368 assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
369 }
370
371 #[test]
372 fn sort_plus_order_builds_ordering() {
373 let opts = ListContractsOptions::builder()
374 .sort("obligated")
375 .order("desc")
376 .build();
377 let q = opts.to_query();
378 assert_eq!(get_q(&q, "ordering").as_deref(), Some("-obligated"));
379 }
380
381 #[test]
382 fn sort_without_order_defaults_ascending() {
383 let opts = ListContractsOptions::builder().sort("award_date").build();
384 let q = opts.to_query();
385 assert_eq!(get_q(&q, "ordering").as_deref(), Some("award_date"));
386 }
387
388 #[test]
389 fn cursor_wins_over_page() {
390 let opts = ListContractsOptions::builder()
391 .page(3u32)
392 .cursor("abc".to_string())
393 .build();
394 let q = opts.to_query();
395 assert_eq!(get_q(&q, "cursor").as_deref(), Some("abc"));
396 assert_eq!(get_q(&q, "page"), None);
397 }
398
399 #[test]
400 fn shape_emits() {
401 let opts = ListContractsOptions::builder()
402 .shape(crate::SHAPE_CONTRACTS_MINIMAL)
403 .build();
404 let q = opts.to_query();
405 assert_eq!(
406 get_q(&q, "shape").as_deref(),
407 Some(crate::SHAPE_CONTRACTS_MINIMAL)
408 );
409 }
410
411 #[tokio::test]
412 async fn get_contract_empty_key_returns_validation() {
413 let client = Client::builder().api_key("x").build().expect("build");
414 let err = client.get_contract("", None).await.expect_err("must error");
415 match err {
416 Error::Validation { message, .. } => assert!(message.contains("key")),
417 other => panic!("expected Validation, got {other:?}"),
418 }
419 }
420
421 #[tokio::test]
422 async fn list_contract_subawards_empty_key_returns_validation() {
423 let client = Client::builder().api_key("x").build().expect("build");
424 let err = client
425 .list_contract_subawards("", None)
426 .await
427 .expect_err("must error");
428 match err {
429 Error::Validation { message, .. } => assert!(message.contains("key")),
430 other => panic!("expected Validation, got {other:?}"),
431 }
432 }
433
434 #[tokio::test]
435 async fn list_contract_transactions_empty_key_returns_validation() {
436 let client = Client::builder().api_key("x").build().expect("build");
437 let err = client
438 .list_contract_transactions("", None)
439 .await
440 .expect_err("must error");
441 match err {
442 Error::Validation { message, .. } => assert!(message.contains("key")),
443 other => panic!("expected Validation, got {other:?}"),
444 }
445 }
446
447 #[test]
448 fn list_contracts_key_filter_emits() {
449 let q = ListContractsOptions::builder()
450 .key("K1|K2")
451 .build()
452 .to_query();
453 assert_eq!(get_q(&q, "key").as_deref(), Some("K1|K2"));
454 }
455}