use crate::client::Client;
use crate::error::{Error, Result};
use crate::internal::{apply_pagination, push_opt};
use crate::pagination::{FetchFn, Page, PageStream};
use crate::resources::agencies::urlencoding;
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 EntitySubresourceOptions {
#[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 joiner: Option<String>,
#[builder(into)]
pub ordering: Option<String>,
#[builder(into)]
pub search: Option<String>,
#[builder(default)]
pub extra: BTreeMap<String, String>,
}
impl EntitySubresourceOptions {
pub(crate) 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,
);
if self.flat {
if let Some(j) = self.joiner.as_deref().filter(|s| !s.is_empty()) {
q.push(("joiner".into(), j.into()));
}
}
push_opt(&mut q, "ordering", self.ordering.as_deref());
push_opt(&mut q, "search", self.search.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_entity_contracts(
&self,
uei: &str,
opts: EntitySubresourceOptions,
) -> Result<Page<Record>> {
list_entity_subresource(self, uei, "contracts", opts).await
}
pub fn iterate_entity_contracts(
&self,
uei: &str,
opts: EntitySubresourceOptions,
) -> PageStream<Record> {
iterate_entity_subresource(self, uei.to_string(), "contracts", opts)
}
pub async fn list_entity_idvs(
&self,
uei: &str,
opts: EntitySubresourceOptions,
) -> Result<Page<Record>> {
list_entity_subresource(self, uei, "idvs", opts).await
}
pub fn iterate_entity_idvs(
&self,
uei: &str,
opts: EntitySubresourceOptions,
) -> PageStream<Record> {
iterate_entity_subresource(self, uei.to_string(), "idvs", opts)
}
pub async fn list_entity_otas(
&self,
uei: &str,
opts: EntitySubresourceOptions,
) -> Result<Page<Record>> {
list_entity_subresource(self, uei, "otas", opts).await
}
pub fn iterate_entity_otas(
&self,
uei: &str,
opts: EntitySubresourceOptions,
) -> PageStream<Record> {
iterate_entity_subresource(self, uei.to_string(), "otas", opts)
}
pub async fn list_entity_otidvs(
&self,
uei: &str,
opts: EntitySubresourceOptions,
) -> Result<Page<Record>> {
list_entity_subresource(self, uei, "otidvs", opts).await
}
pub fn iterate_entity_otidvs(
&self,
uei: &str,
opts: EntitySubresourceOptions,
) -> PageStream<Record> {
iterate_entity_subresource(self, uei.to_string(), "otidvs", opts)
}
pub async fn list_entity_subawards(
&self,
uei: &str,
opts: EntitySubresourceOptions,
) -> Result<Page<Record>> {
list_entity_subresource(self, uei, "subawards", opts).await
}
pub fn iterate_entity_subawards(
&self,
uei: &str,
opts: EntitySubresourceOptions,
) -> PageStream<Record> {
iterate_entity_subresource(self, uei.to_string(), "subawards", opts)
}
pub async fn list_entity_lcats(
&self,
uei: &str,
opts: EntitySubresourceOptions,
) -> Result<Page<Record>> {
list_entity_subresource(self, uei, "lcats", opts).await
}
pub fn iterate_entity_lcats(
&self,
uei: &str,
opts: EntitySubresourceOptions,
) -> PageStream<Record> {
iterate_entity_subresource(self, uei.to_string(), "lcats", opts)
}
pub async fn get_entity_metrics(
&self,
uei: &str,
months: u32,
period_grouping: &str,
) -> Result<Record> {
if uei.is_empty() {
return Err(Error::Validation {
message: "get_entity_metrics: uei is required".into(),
response: None,
});
}
if months == 0 {
return Err(Error::Validation {
message: "get_entity_metrics: months must be > 0".into(),
response: None,
});
}
if period_grouping.is_empty() {
return Err(Error::Validation {
message: "get_entity_metrics: period_grouping is required".into(),
response: None,
});
}
let path = format!(
"/api/entities/{}/metrics/{}/{}/",
urlencoding(uei),
months,
urlencoding(period_grouping),
);
self.get_json::<Record>(&path, &[]).await
}
}
async fn list_entity_subresource(
client: &Client,
uei: &str,
segment: &str,
opts: EntitySubresourceOptions,
) -> Result<Page<Record>> {
if uei.is_empty() {
return Err(Error::Validation {
message: "entity sub-resource: uei is required".into(),
response: None,
});
}
let q = opts.to_query();
let path = format!("/api/entities/{}/{segment}/", urlencoding(uei));
let bytes = client.get_bytes(&path, &q).await?;
Page::decode(&bytes)
}
fn iterate_entity_subresource(
client: &Client,
uei: String,
segment: &'static str,
opts: EntitySubresourceOptions,
) -> PageStream<Record> {
let opts = Arc::new(opts);
let uei = Arc::new(uei);
let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
let mut next = (*opts).clone();
next.page = page;
next.cursor = cursor;
let uei = uei.clone();
Box::pin(async move { list_entity_subresource(&client, &uei, segment, next).await })
});
PageStream::new(client.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 options_emit_pagination_shape_and_search() {
let opts = EntitySubresourceOptions::builder()
.limit(10u32)
.shape("contracts(minimal)")
.ordering("-award_date")
.search("software")
.build();
let q = opts.to_query();
assert_eq!(get_q(&q, "limit").as_deref(), Some("10"));
assert_eq!(get_q(&q, "shape").as_deref(), Some("contracts(minimal)"));
assert_eq!(get_q(&q, "ordering").as_deref(), Some("-award_date"));
assert_eq!(get_q(&q, "search").as_deref(), Some("software"));
assert!(!q.iter().any(|(k, _)| k == "joiner"));
}
#[test]
fn joiner_only_when_flat() {
let opts = EntitySubresourceOptions::builder()
.joiner("__".to_string())
.build();
let q = opts.to_query();
assert!(!q.iter().any(|(k, _)| k == "joiner"));
let opts = EntitySubresourceOptions::builder()
.flat(true)
.joiner("__".to_string())
.build();
let q = opts.to_query();
assert!(q.contains(&("joiner".into(), "__".into())));
}
#[test]
fn extra_forwards_arbitrary_params() {
let mut extra = BTreeMap::new();
extra.insert("custom_x".to_string(), "val".to_string());
let opts = EntitySubresourceOptions::builder().extra(extra).build();
let q = opts.to_query();
assert!(q.contains(&("custom_x".into(), "val".into())));
}
#[tokio::test]
async fn list_entity_contracts_empty_uei_returns_validation() {
let client = Client::builder().api_key("x").build().expect("build");
let err = client
.list_entity_contracts("", EntitySubresourceOptions::default())
.await
.expect_err("must error");
match err {
Error::Validation { message, .. } => assert!(message.contains("uei")),
other => panic!("expected Validation, got {other:?}"),
}
}
#[tokio::test]
async fn list_entity_subawards_empty_uei_returns_validation() {
let client = Client::builder().api_key("x").build().expect("build");
let err = client
.list_entity_subawards("", EntitySubresourceOptions::default())
.await
.expect_err("must error");
match err {
Error::Validation { message, .. } => assert!(message.contains("uei")),
other => panic!("expected Validation, got {other:?}"),
}
}
#[tokio::test]
async fn get_entity_metrics_empty_uei_returns_validation() {
let client = Client::builder().api_key("x").build().expect("build");
let err = client
.get_entity_metrics("", 12, "month")
.await
.expect_err("must error");
match err {
Error::Validation { message, .. } => assert!(message.contains("uei")),
other => panic!("expected Validation, got {other:?}"),
}
}
#[tokio::test]
async fn get_entity_metrics_zero_months_returns_validation() {
let client = Client::builder().api_key("x").build().expect("build");
let err = client
.get_entity_metrics("ABC123DEF456", 0, "month")
.await
.expect_err("must error");
assert!(matches!(err, Error::Validation { .. }));
}
#[tokio::test]
async fn get_entity_metrics_empty_period_grouping_returns_validation() {
let client = Client::builder().api_key("x").build().expect("build");
let err = client
.get_entity_metrics("ABC123DEF456", 12, "")
.await
.expect_err("must error");
assert!(matches!(err, Error::Validation { .. }));
}
}