use crate::client::Client;
use crate::error::Result;
use crate::internal::{apply_pagination, first_non_empty, push_opt};
use crate::pagination::{FetchFn, Page, PageStream};
use crate::Record;
use bon::Builder;
use std::collections::BTreeMap;
use std::sync::Arc;
#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
#[non_exhaustive]
pub struct ListContractsOptions {
#[builder(into)]
pub page: Option<u32>,
#[builder(into)]
pub limit: Option<u32>,
#[builder(into)]
pub cursor: Option<String>,
#[builder(into)]
pub shape: Option<String>,
#[builder(default)]
pub flat: bool,
#[builder(default)]
pub flat_lists: bool,
#[builder(into)]
pub award_date: Option<String>,
#[builder(into)]
pub award_date_gte: Option<String>,
#[builder(into)]
pub award_date_lte: Option<String>,
#[builder(into)]
pub award_type: Option<String>,
#[builder(into)]
pub fiscal_year: Option<String>,
#[builder(into)]
pub fiscal_year_gte: Option<String>,
#[builder(into)]
pub fiscal_year_lte: Option<String>,
#[builder(into)]
pub obligated_gte: Option<String>,
#[builder(into)]
pub obligated_lte: Option<String>,
#[builder(into)]
pub pop_start_date_gte: Option<String>,
#[builder(into)]
pub pop_start_date_lte: Option<String>,
#[builder(into)]
pub pop_end_date_gte: Option<String>,
#[builder(into)]
pub pop_end_date_lte: Option<String>,
#[builder(into)]
pub expiring_gte: Option<String>,
#[builder(into)]
pub expiring_lte: Option<String>,
#[builder(into)]
pub awarding_agency: Option<String>,
#[builder(into)]
pub funding_agency: Option<String>,
#[builder(into)]
pub piid: Option<String>,
#[builder(into)]
pub solicitation_identifier: Option<String>,
#[builder(into)]
pub naics: Option<String>,
#[builder(into)]
pub psc: Option<String>,
#[builder(into)]
pub recipient: Option<String>,
#[builder(into)]
pub uei: Option<String>,
#[builder(into)]
pub set_aside: Option<String>,
#[builder(into)]
pub naics_code: Option<String>,
#[builder(into)]
pub psc_code: Option<String>,
#[builder(into)]
pub recipient_name: Option<String>,
#[builder(into)]
pub recipient_uei: Option<String>,
#[builder(into)]
pub set_aside_type: Option<String>,
#[builder(into)]
pub search: Option<String>,
#[builder(into)]
pub keyword: Option<String>,
#[builder(into)]
pub ordering: Option<String>,
#[builder(into)]
pub sort: Option<String>,
#[builder(into)]
pub order: Option<String>,
#[builder(default)]
pub extra: BTreeMap<String, String>,
}
impl ListContractsOptions {
fn to_query(&self) -> Vec<(String, String)> {
let mut q = Vec::new();
apply_pagination(
&mut q,
self.page,
self.limit,
self.cursor.as_deref(),
self.shape.as_deref(),
self.flat,
self.flat_lists,
);
push_opt(&mut q, "award_date", self.award_date.as_deref());
push_opt(&mut q, "award_date_gte", self.award_date_gte.as_deref());
push_opt(&mut q, "award_date_lte", self.award_date_lte.as_deref());
push_opt(&mut q, "award_type", self.award_type.as_deref());
push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
push_opt(&mut q, "obligated_gte", self.obligated_gte.as_deref());
push_opt(&mut q, "obligated_lte", self.obligated_lte.as_deref());
push_opt(
&mut q,
"pop_start_date_gte",
self.pop_start_date_gte.as_deref(),
);
push_opt(
&mut q,
"pop_start_date_lte",
self.pop_start_date_lte.as_deref(),
);
push_opt(&mut q, "pop_end_date_gte", self.pop_end_date_gte.as_deref());
push_opt(&mut q, "pop_end_date_lte", self.pop_end_date_lte.as_deref());
push_opt(&mut q, "expiring_gte", self.expiring_gte.as_deref());
push_opt(&mut q, "expiring_lte", self.expiring_lte.as_deref());
push_opt(&mut q, "awarding_agency", self.awarding_agency.as_deref());
push_opt(&mut q, "funding_agency", self.funding_agency.as_deref());
push_opt(&mut q, "piid", self.piid.as_deref());
push_opt(
&mut q,
"solicitation_identifier",
self.solicitation_identifier.as_deref(),
);
push_opt(
&mut q,
"naics",
first_non_empty(&[self.naics_code.as_deref(), self.naics.as_deref()]),
);
push_opt(
&mut q,
"psc",
first_non_empty(&[self.psc_code.as_deref(), self.psc.as_deref()]),
);
push_opt(
&mut q,
"recipient",
first_non_empty(&[self.recipient_name.as_deref(), self.recipient.as_deref()]),
);
push_opt(
&mut q,
"uei",
first_non_empty(&[self.recipient_uei.as_deref(), self.uei.as_deref()]),
);
push_opt(
&mut q,
"set_aside",
first_non_empty(&[self.set_aside_type.as_deref(), self.set_aside.as_deref()]),
);
push_opt(
&mut q,
"search",
first_non_empty(&[self.keyword.as_deref(), self.search.as_deref()]),
);
if let Some(sort) = self.sort.as_deref().filter(|s| !s.is_empty()) {
let order = self.order.as_deref().unwrap_or("");
let prefix = if order == "desc" { "-" } else { "" };
q.push(("ordering".into(), format!("{prefix}{sort}")));
} else {
push_opt(&mut q, "ordering", self.ordering.as_deref());
}
for (k, v) in &self.extra {
if !v.is_empty() {
q.push((k.clone(), v.clone()));
}
}
q
}
}
impl Client {
pub async fn list_contracts(&self, opts: ListContractsOptions) -> Result<Page<Record>> {
let q = opts.to_query();
let bytes = self.get_bytes("/api/contracts/", &q).await?;
Page::decode(&bytes)
}
pub fn iterate_contracts(&self, opts: ListContractsOptions) -> PageStream<Record> {
let opts = Arc::new(opts);
let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
let mut next = (*opts).clone();
next.page = page;
next.cursor = cursor;
Box::pin(async move { client.list_contracts(next).await })
});
PageStream::new(self.clone(), fetch)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
}
#[test]
fn alias_prefers_sdk_name_over_canonical() {
let opts = ListContractsOptions::builder()
.naics_code("541512")
.naics("999999")
.build();
let q = opts.to_query();
assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
}
#[test]
fn alias_falls_through_to_canonical() {
let opts = ListContractsOptions::builder().naics("541512").build();
let q = opts.to_query();
assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
}
#[test]
fn sort_plus_order_builds_ordering() {
let opts = ListContractsOptions::builder()
.sort("obligated")
.order("desc")
.build();
let q = opts.to_query();
assert_eq!(get_q(&q, "ordering").as_deref(), Some("-obligated"));
}
#[test]
fn sort_without_order_defaults_ascending() {
let opts = ListContractsOptions::builder().sort("award_date").build();
let q = opts.to_query();
assert_eq!(get_q(&q, "ordering").as_deref(), Some("award_date"));
}
#[test]
fn cursor_wins_over_page() {
let opts = ListContractsOptions::builder()
.page(3u32)
.cursor("abc".to_string())
.build();
let q = opts.to_query();
assert_eq!(get_q(&q, "cursor").as_deref(), Some("abc"));
assert_eq!(get_q(&q, "page"), None);
}
#[test]
fn shape_emits() {
let opts = ListContractsOptions::builder()
.shape(crate::SHAPE_CONTRACTS_MINIMAL)
.build();
let q = opts.to_query();
assert_eq!(
get_q(&q, "shape").as_deref(),
Some(crate::SHAPE_CONTRACTS_MINIMAL)
);
}
}