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 commercial_item_acquisition_procedures: Option<String>,
121 #[builder(into)]
123 pub set_aside: Option<String>,
124
125 #[builder(into)]
128 pub naics_code: Option<String>,
129 #[builder(into)]
131 pub psc_code: Option<String>,
132 #[builder(into)]
134 pub recipient_name: Option<String>,
135 #[builder(into)]
137 pub recipient_uei: Option<String>,
138 #[builder(into)]
140 pub set_aside_type: Option<String>,
141
142 #[builder(into)]
144 pub search: Option<String>,
145 #[builder(into)]
147 pub keyword: Option<String>,
148 #[builder(into)]
150 pub ordering: Option<String>,
151 #[builder(into)]
153 pub sort: Option<String>,
154 #[builder(into)]
156 pub order: Option<String>,
157
158 #[builder(into)]
160 pub key: Option<String>,
161
162 #[builder(default)]
164 pub extra: BTreeMap<String, String>,
165}
166
167impl ListContractsOptions {
168 fn to_query(&self) -> Vec<(String, String)> {
169 let mut q = Vec::new();
170 apply_pagination(
171 &mut q,
172 self.page,
173 self.limit,
174 self.cursor.as_deref(),
175 self.shape.as_deref(),
176 self.flat,
177 self.flat_lists,
178 );
179
180 push_opt(&mut q, "award_date", self.award_date.as_deref());
181 push_opt(&mut q, "award_date_gte", self.award_date_gte.as_deref());
182 push_opt(&mut q, "award_date_lte", self.award_date_lte.as_deref());
183 push_opt(&mut q, "award_type", self.award_type.as_deref());
184 push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
185 push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
186 push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
187 push_opt(&mut q, "obligated_gte", self.obligated_gte.as_deref());
188 push_opt(&mut q, "obligated_lte", self.obligated_lte.as_deref());
189 push_opt(
190 &mut q,
191 "pop_start_date_gte",
192 self.pop_start_date_gte.as_deref(),
193 );
194 push_opt(
195 &mut q,
196 "pop_start_date_lte",
197 self.pop_start_date_lte.as_deref(),
198 );
199 push_opt(&mut q, "pop_end_date_gte", self.pop_end_date_gte.as_deref());
200 push_opt(&mut q, "pop_end_date_lte", self.pop_end_date_lte.as_deref());
201 push_opt(&mut q, "expiring_gte", self.expiring_gte.as_deref());
202 push_opt(&mut q, "expiring_lte", self.expiring_lte.as_deref());
203 push_opt(&mut q, "awarding_agency", self.awarding_agency.as_deref());
204 push_opt(&mut q, "funding_agency", self.funding_agency.as_deref());
205 push_opt(&mut q, "piid", self.piid.as_deref());
206 push_opt(&mut q, "key", self.key.as_deref());
207 push_opt(
208 &mut q,
209 "solicitation_identifier",
210 self.solicitation_identifier.as_deref(),
211 );
212
213 push_opt(
215 &mut q,
216 "naics",
217 first_non_empty(&[self.naics_code.as_deref(), self.naics.as_deref()]),
218 );
219 push_opt(
220 &mut q,
221 "psc",
222 first_non_empty(&[self.psc_code.as_deref(), self.psc.as_deref()]),
223 );
224 push_opt(
225 &mut q,
226 "recipient",
227 first_non_empty(&[self.recipient_name.as_deref(), self.recipient.as_deref()]),
228 );
229 push_opt(
230 &mut q,
231 "uei",
232 first_non_empty(&[self.recipient_uei.as_deref(), self.uei.as_deref()]),
233 );
234 push_opt(
235 &mut q,
236 "commercial_item_acquisition_procedures",
237 self.commercial_item_acquisition_procedures.as_deref(),
238 );
239 push_opt(
240 &mut q,
241 "set_aside",
242 first_non_empty(&[self.set_aside_type.as_deref(), self.set_aside.as_deref()]),
243 );
244 push_opt(
245 &mut q,
246 "search",
247 first_non_empty(&[self.keyword.as_deref(), self.search.as_deref()]),
248 );
249
250 if let Some(sort) = self.sort.as_deref().filter(|s| !s.is_empty()) {
251 let order = self.order.as_deref().unwrap_or("");
252 let prefix = if order == "desc" { "-" } else { "" };
253 q.push(("ordering".into(), format!("{prefix}{sort}")));
254 } else {
255 push_opt(&mut q, "ordering", self.ordering.as_deref());
256 }
257
258 for (k, v) in &self.extra {
259 if !v.is_empty() {
260 q.push((k.clone(), v.clone()));
261 }
262 }
263 q
264 }
265}
266
267impl Client {
268 pub async fn list_contracts(&self, opts: ListContractsOptions) -> Result<Page<Record>> {
273 let q = opts.to_query();
274 self.get_page("/api/contracts/", &q).await
275 }
276
277 pub async fn get_contract(&self, key: &str, opts: Option<ListOptions>) -> Result<Record> {
279 if key.is_empty() {
280 return Err(Error::Validation {
281 message: "get_contract: key is required".into(),
282 response: None,
283 });
284 }
285 let mut q = Vec::new();
286 opts.unwrap_or_default().apply(&mut q);
287 let path = format!("/api/contracts/{}/", urlencoding(key));
288 self.get_json::<Record>(&path, &q).await
289 }
290
291 pub async fn list_contract_subawards(
294 &self,
295 key: &str,
296 opts: Option<EntitySubresourceOptions>,
297 ) -> Result<Page<Record>> {
298 if key.is_empty() {
299 return Err(Error::Validation {
300 message: "list_contract_subawards: key is required".into(),
301 response: None,
302 });
303 }
304 let q = opts.unwrap_or_default().to_query();
305 let path = format!("/api/contracts/{}/subawards/", urlencoding(key));
306 self.get_page(&path, &q).await
307 }
308
309 pub async fn list_contract_transactions(
312 &self,
313 key: &str,
314 opts: Option<EntitySubresourceOptions>,
315 ) -> Result<Page<Record>> {
316 if key.is_empty() {
317 return Err(Error::Validation {
318 message: "list_contract_transactions: key is required".into(),
319 response: None,
320 });
321 }
322 let q = opts.unwrap_or_default().to_query();
323 let path = format!("/api/contracts/{}/transactions/", urlencoding(key));
324 self.get_page(&path, &q).await
325 }
326
327 pub fn iterate_contracts(&self, opts: ListContractsOptions) -> PageStream<Record> {
343 let opts = Arc::new(opts);
344 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
345 let mut next = (*opts).clone();
346 next.page = page;
347 next.cursor = cursor;
348 Box::pin(async move { client.list_contracts(next).await })
349 });
350 PageStream::new(self.clone(), fetch)
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
359 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
360 }
361
362 #[test]
363 fn alias_prefers_sdk_name_over_canonical() {
364 let opts = ListContractsOptions::builder()
365 .naics_code("541512")
366 .naics("999999")
367 .build();
368 let q = opts.to_query();
369 assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
370 }
371
372 #[test]
373 fn alias_falls_through_to_canonical() {
374 let opts = ListContractsOptions::builder().naics("541512").build();
375 let q = opts.to_query();
376 assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
377 }
378
379 #[test]
380 fn sort_plus_order_builds_ordering() {
381 let opts = ListContractsOptions::builder()
382 .sort("obligated")
383 .order("desc")
384 .build();
385 let q = opts.to_query();
386 assert_eq!(get_q(&q, "ordering").as_deref(), Some("-obligated"));
387 }
388
389 #[test]
390 fn sort_without_order_defaults_ascending() {
391 let opts = ListContractsOptions::builder().sort("award_date").build();
392 let q = opts.to_query();
393 assert_eq!(get_q(&q, "ordering").as_deref(), Some("award_date"));
394 }
395
396 #[test]
397 fn cursor_wins_over_page() {
398 let opts = ListContractsOptions::builder()
399 .page(3u32)
400 .cursor("abc".to_string())
401 .build();
402 let q = opts.to_query();
403 assert_eq!(get_q(&q, "cursor").as_deref(), Some("abc"));
404 assert_eq!(get_q(&q, "page"), None);
405 }
406
407 #[test]
408 fn shape_emits() {
409 let opts = ListContractsOptions::builder()
410 .shape(crate::SHAPE_CONTRACTS_MINIMAL)
411 .build();
412 let q = opts.to_query();
413 assert_eq!(
414 get_q(&q, "shape").as_deref(),
415 Some(crate::SHAPE_CONTRACTS_MINIMAL)
416 );
417 }
418
419 #[tokio::test]
420 async fn get_contract_empty_key_returns_validation() {
421 let client = Client::builder().api_key("x").build().expect("build");
422 let err = client.get_contract("", None).await.expect_err("must error");
423 match err {
424 Error::Validation { message, .. } => assert!(message.contains("key")),
425 other => panic!("expected Validation, got {other:?}"),
426 }
427 }
428
429 #[tokio::test]
430 async fn list_contract_subawards_empty_key_returns_validation() {
431 let client = Client::builder().api_key("x").build().expect("build");
432 let err = client
433 .list_contract_subawards("", None)
434 .await
435 .expect_err("must error");
436 match err {
437 Error::Validation { message, .. } => assert!(message.contains("key")),
438 other => panic!("expected Validation, got {other:?}"),
439 }
440 }
441
442 #[tokio::test]
443 async fn list_contract_transactions_empty_key_returns_validation() {
444 let client = Client::builder().api_key("x").build().expect("build");
445 let err = client
446 .list_contract_transactions("", None)
447 .await
448 .expect_err("must error");
449 match err {
450 Error::Validation { message, .. } => assert!(message.contains("key")),
451 other => panic!("expected Validation, got {other:?}"),
452 }
453 }
454
455 #[test]
456 fn list_contracts_key_filter_emits() {
457 let q = ListContractsOptions::builder()
458 .key("K1|K2")
459 .build()
460 .to_query();
461 assert_eq!(get_q(&q, "key").as_deref(), Some("K1|K2"));
462 }
463
464 #[test]
465 fn list_contracts_commercial_item_acquisition_procedures_emits() {
466 let q = ListContractsOptions::builder()
467 .commercial_item_acquisition_procedures("A|E")
468 .build()
469 .to_query();
470 assert_eq!(
471 get_q(&q, "commercial_item_acquisition_procedures").as_deref(),
472 Some("A|E")
473 );
474 }
475}