From 5be62254626aa3db39b786f0943fbd7b886fd4eb Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Thu, 28 May 2026 07:32:22 -0500 Subject: [PATCH 1/5] feat: sync SDK to Tango API v4.6.9 (v0.2.0) Add the budget surface (/api/budget/accounts/ list/retrieve/quarters/ recipients), singleton detail GETs (contract/opportunity/notice/forecast/ grant/subaward), contract sub-routes (subawards/transactions), entity budget-flows, and grant_id + cage filters. Remove the fabricated get_idv_summary / list_idv_summary_awards methods (paths never existed upstream; breaking but free pre-1.0). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 15 + Cargo.lock | 4 +- Cargo.toml | 2 +- ROADMAP.md | 11 +- crates/tango/src/lib.rs | 8 +- crates/tango/src/resources/budget.rs | 300 ++++++++++++++++++ crates/tango/src/resources/contracts.rs | 94 +++++- crates/tango/src/resources/entities.rs | 7 + .../src/resources/entity_subresources.rs | 24 ++ .../tango/src/resources/idv_subresources.rs | 48 +-- crates/tango/src/resources/mod.rs | 2 + crates/tango/src/resources/opportunities.rs | 112 ++++++- crates/tango/src/resources/subawards.rs | 29 +- crates/tango/src/shapes.rs | 7 + docs/API_REFERENCE.md | 30 +- 15 files changed, 629 insertions(+), 64 deletions(-) create mode 100644 crates/tango/src/resources/budget.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index bf7d783..9d5f77b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ This project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +Sync to Tango API v4.6.9. Pre-1.0 (SemVer 0.x): the removals below are breaking but ship without a deprecation cycle. + +### Added + +- **Budget surface** (`budget.rs`): `list_budget_accounts` / `iterate_budget_accounts` (`GET /api/budget/accounts/`), `get_budget_account` (`GET /api/budget/accounts/{id}/`), `get_budget_account_quarters` (`GET /api/budget/accounts/{id}/quarters/`), `get_budget_account_recipients` (`GET /api/budget/accounts/{id}/recipients/`). New `ListBudgetAccountsOptions` builder and `SHAPE_BUDGET_ACCOUNTS_MINIMAL` shape constant. +- Singleton detail GETs: `get_contract` (`GET /api/contracts/{key}/`), `get_opportunity`, `get_notice`, `get_forecast`, `get_grant`, `get_subaward`. +- Contract sub-routes: `list_contract_subawards` (`GET /api/contracts/{key}/subawards/`), `list_contract_transactions` (`GET /api/contracts/{key}/transactions/`). +- `get_entity_budget_flows` (`GET /api/entities/{uei}/budget-flows/`). +- `grant_id` typed filter on `ListGrantsOptions`. +- `cage` typed filter on `ListEntitiesOptions` (distinct from the existing `cage_code`; the server rejects setting both). + +### Removed + +- **Breaking**: `get_idv_summary` and `list_idv_summary_awards`. These hit `/api/idvs/{key}/summary/` and `/api/idvs/{key}/summary/awards/`, which have never existed in the Tango API (the server returns 404). Use `get_idv` with a comprehensive shape and `list_idv_awards` respectively. + ### `makegov-tango` #### Added diff --git a/Cargo.lock b/Cargo.lock index e209d5f..7e901f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1173,7 +1173,7 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "makegov-tango" -version = "0.1.0" +version = "0.2.0" dependencies = [ "bon", "futures", @@ -1191,7 +1191,7 @@ dependencies = [ [[package]] name = "makegov-tango-webhooks" -version = "0.1.0" +version = "0.2.0" dependencies = [ "hex", "hmac", diff --git a/Cargo.toml b/Cargo.toml index 397d50b..8419be0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ exclude = ["examples"] [workspace.package] -version = "0.1.0" +version = "0.2.0" edition = "2021" rust-version = "1.80" license = "MIT" diff --git a/ROADMAP.md b/ROADMAP.md index b3b0eac..6862e3e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,7 +15,16 @@ This roadmap tracks the Rust SDK only. The goal is to stay closely aligned with - [X] Sub-resource walks for IDVs, entities, agencies, vehicles. - [X] OTAs / OTIDVs, GSA eLibrary, IT Dashboard, protests, LCATs. -## Next (0.2) +## 0.2 (API sync to v4.6.9) + +- [X] Budget surface: `list_budget_accounts` / `iterate_budget_accounts`, `get_budget_account`, `get_budget_account_quarters`, `get_budget_account_recipients` (`/api/budget/accounts/`). +- [X] Singleton detail GETs: `get_contract`, `get_opportunity`, `get_notice`, `get_forecast`, `get_grant`, `get_subaward`. +- [X] Contract sub-routes: `list_contract_subawards`, `list_contract_transactions`. +- [X] `get_entity_budget_flows` (`/api/entities/{uei}/budget-flows/`). +- [X] `grant_id` filter on grants; `cage` filter on entities. +- [X] Removed fabricated `get_idv_summary` / `list_idv_summary_awards` (paths never existed upstream). + +## Next - [ ] Edition 2024 migration once MSRV catches up (re-evaluate `rust-version` floor). - [ ] Optional `blocking` feature (sync facade over the async client, gated behind a Cargo feature). diff --git a/crates/tango/src/lib.rs b/crates/tango/src/lib.rs index 6435426..b435054 100644 --- a/crates/tango/src/lib.rs +++ b/crates/tango/src/lib.rs @@ -119,10 +119,10 @@ pub use error::{Error, ErrorBody, Result}; pub use internal::ListOptions; pub use pagination::{Page, PageStream}; pub use shapes::{ - DEFAULT_BASE_URL, SHAPE_CONTRACTS_MINIMAL, SHAPE_CONTRACT_APPEALS_MINIMAL, - SHAPE_ENTITIES_COMPREHENSIVE, SHAPE_ENTITIES_MINIMAL, SHAPE_FORECASTS_MINIMAL, - SHAPE_GRANTS_MINIMAL, SHAPE_GSA_ELIBRARY_CONTRACTS_MINIMAL, SHAPE_IDVS_COMPREHENSIVE, - SHAPE_IDVS_MINIMAL, SHAPE_ITDASHBOARD_INVESTMENTS_COMPREHENSIVE, + DEFAULT_BASE_URL, SHAPE_BUDGET_ACCOUNTS_MINIMAL, SHAPE_CONTRACTS_MINIMAL, + SHAPE_CONTRACT_APPEALS_MINIMAL, SHAPE_ENTITIES_COMPREHENSIVE, SHAPE_ENTITIES_MINIMAL, + SHAPE_FORECASTS_MINIMAL, SHAPE_GRANTS_MINIMAL, SHAPE_GSA_ELIBRARY_CONTRACTS_MINIMAL, + SHAPE_IDVS_COMPREHENSIVE, SHAPE_IDVS_MINIMAL, SHAPE_ITDASHBOARD_INVESTMENTS_COMPREHENSIVE, SHAPE_ITDASHBOARD_INVESTMENTS_MINIMAL, SHAPE_NOTICES_MINIMAL, SHAPE_OPPORTUNITIES_MINIMAL, SHAPE_ORGANIZATIONS_MINIMAL, SHAPE_OTAS_MINIMAL, SHAPE_OTIDVS_MINIMAL, SHAPE_PROTESTS_MINIMAL, SHAPE_SLED_FORECASTS_COMPREHENSIVE, SHAPE_SLED_FORECASTS_MINIMAL, diff --git a/crates/tango/src/resources/budget.rs b/crates/tango/src/resources/budget.rs new file mode 100644 index 0000000..9c48318 --- /dev/null +++ b/crates/tango/src/resources/budget.rs @@ -0,0 +1,300 @@ +//! `/api/budget/accounts/` — federal-account x fiscal-year budget rollups. +//! +//! One row per `(federal_account_symbol, fiscal_year)` covering the full +//! budget lifecycle (requested → enacted → apportioned → obligated → +//! outlayed), pre-computed ratios + trends, the contract/assistance/unlinked +//! breakdown, and request-vs-actual contract spend. The schema is wide +//! (~63 fields) and shape-driven, so every method returns the untyped +//! [`Record`] map like the other resource families. + +use crate::client::Client; +use crate::error::{Error, Result}; +use crate::internal::{apply_pagination, push_opt, ListOptions}; +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; + +/// Options for [`Client::list_budget_accounts`] and +/// [`Client::iterate_budget_accounts`]. Mirrors `ListBudgetAccountsOptions` +/// in the Go SDK. +/// +/// The full `__gte` / `__lte` range-filter set (the 26 numeric metrics on the +/// underlying FilterSet) is reachable via the [`extra`](Self::extra) map. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct ListBudgetAccountsOptions { + // ----- Pagination + shape ----- + /// 1-based page number. Mutually exclusive with [`cursor`](Self::cursor). + #[builder(into)] + pub page: Option, + /// Page size (server caps at 100). + #[builder(into)] + pub limit: Option, + /// Keyset cursor. + #[builder(into)] + pub cursor: Option, + /// Comma-separated field selector. Use + /// [`SHAPE_BUDGET_ACCOUNTS_MINIMAL`](crate::SHAPE_BUDGET_ACCOUNTS_MINIMAL) + /// or roll your own. + #[builder(into)] + pub shape: Option, + /// Collapse nested objects into dot-separated keys. + #[builder(default)] + pub flat: bool, + /// When [`flat`](Self::flat) is also true, flatten list-valued fields. + #[builder(default)] + pub flat_lists: bool, + + // ----- Resource filters ----- + /// Federal account symbol filter (exact, e.g. `"097-0100"`). + #[builder(into)] + pub federal_account_symbol: Option, + /// `fiscal_year` filter (exact). + #[builder(into)] + pub fiscal_year: Option, + /// Lower bound for `fiscal_year` (inclusive). + #[builder(into)] + pub fiscal_year_gte: Option, + /// Upper bound for `fiscal_year` (inclusive). + #[builder(into)] + pub fiscal_year_lte: Option, + /// Awarding/funding agency CGAC code filter (exact). + #[builder(into)] + pub agency_code: Option, + /// Bureau of Economic Analysis category filter (exact). + #[builder(into)] + pub bea_category: Option, + /// On/off-budget flag filter (exact). + #[builder(into)] + pub on_off_budget: Option, + /// Free-text search filter. + #[builder(into)] + pub search: Option, + /// Server-side sort spec (prefix `-` for descending). + #[builder(into)] + pub ordering: Option, + + /// Escape hatch for filter keys not yet first-classed on this struct + /// (e.g. the `*_gte` / `*_lte` range filters on the numeric metrics). + #[builder(default)] + pub extra: BTreeMap, +} + +impl ListBudgetAccountsOptions { + 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, + "federal_account_symbol", + self.federal_account_symbol.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, "agency_code", self.agency_code.as_deref()); + push_opt(&mut q, "bea_category", self.bea_category.as_deref()); + push_opt(&mut q, "on_off_budget", self.on_off_budget.as_deref()); + push_opt(&mut q, "search", self.search.as_deref()); + 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 { + /// `GET /api/budget/accounts/` — one page of budget-account rollups. + pub async fn list_budget_accounts( + &self, + opts: ListBudgetAccountsOptions, + ) -> Result> { + let q = opts.to_query(); + let bytes = self.get_bytes("/api/budget/accounts/", &q).await?; + Page::decode(&bytes) + } + + /// Stream every budget-account rollup matching `opts`. + pub fn iterate_budget_accounts(&self, opts: ListBudgetAccountsOptions) -> PageStream { + let opts = Arc::new(opts); + let fetch: FetchFn = Box::new(move |client, page, cursor| { + let mut next = (*opts).clone(); + next.page = page; + next.cursor = cursor; + Box::pin(async move { client.list_budget_accounts(next).await }) + }); + PageStream::new(self.clone(), fetch) + } + + /// `GET /api/budget/accounts/{id}/` — a single budget-account rollup. + pub async fn get_budget_account(&self, id: &str, opts: Option) -> Result { + if id.is_empty() { + return Err(Error::Validation { + message: "get_budget_account: id is required".into(), + response: None, + }); + } + let mut q = Vec::new(); + opts.unwrap_or_default().apply(&mut q); + let path = format!("/api/budget/accounts/{}/", urlencoding(id)); + self.get_json::(&path, &q).await + } + + /// `GET /api/budget/accounts/{id}/quarters/` — quarterly lifecycle detail + /// for a single account-year. + pub async fn get_budget_account_quarters( + &self, + id: &str, + opts: Option, + ) -> Result> { + if id.is_empty() { + return Err(Error::Validation { + message: "get_budget_account_quarters: id is required".into(), + response: None, + }); + } + let mut q = Vec::new(); + opts.unwrap_or_default().apply(&mut q); + let path = format!("/api/budget/accounts/{}/quarters/", urlencoding(id)); + let bytes = self.get_bytes(&path, &q).await?; + Page::decode(&bytes) + } + + /// `GET /api/budget/accounts/{id}/recipients/` — funding-office x recipient + /// contract-flow detail for a single account-year. The response envelope + /// carries extra keys (`federal_account_symbol`, `fiscal_year`) alongside + /// the standard pagination fields, so callers should navigate the returned + /// [`Record`] structure directly. + pub async fn get_budget_account_recipients( + &self, + id: &str, + opts: Option, + ) -> Result> { + if id.is_empty() { + return Err(Error::Validation { + message: "get_budget_account_recipients: id is required".into(), + response: None, + }); + } + let mut q = Vec::new(); + opts.unwrap_or_default().apply(&mut q); + let path = format!("/api/budget/accounts/{}/recipients/", urlencoding(id)); + let bytes = self.get_bytes(&path, &q).await?; + Page::decode(&bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn get_q(q: &[(String, String)], k: &str) -> Option { + q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone()) + } + + #[test] + fn options_emit_all_filters() { + let opts = ListBudgetAccountsOptions::builder() + .federal_account_symbol("097-0100") + .fiscal_year("2024") + .fiscal_year_gte("2020") + .fiscal_year_lte("2025") + .agency_code("9700") + .bea_category("discretionary") + .on_off_budget("on") + .search("operations") + .ordering("-enacted_ba") + .build(); + let q = opts.to_query(); + assert_eq!( + get_q(&q, "federal_account_symbol").as_deref(), + Some("097-0100") + ); + assert_eq!(get_q(&q, "fiscal_year").as_deref(), Some("2024")); + assert_eq!(get_q(&q, "fiscal_year__gte").as_deref(), Some("2020")); + assert_eq!(get_q(&q, "fiscal_year__lte").as_deref(), Some("2025")); + assert_eq!(get_q(&q, "agency_code").as_deref(), Some("9700")); + assert_eq!(get_q(&q, "bea_category").as_deref(), Some("discretionary")); + assert_eq!(get_q(&q, "on_off_budget").as_deref(), Some("on")); + assert_eq!(get_q(&q, "search").as_deref(), Some("operations")); + assert_eq!(get_q(&q, "ordering").as_deref(), Some("-enacted_ba")); + } + + #[test] + fn pagination_and_shape_emit() { + let opts = ListBudgetAccountsOptions::builder() + .page(2u32) + .limit(50u32) + .shape(crate::SHAPE_BUDGET_ACCOUNTS_MINIMAL) + .build(); + let q = opts.to_query(); + assert_eq!(get_q(&q, "page").as_deref(), Some("2")); + assert_eq!(get_q(&q, "limit").as_deref(), Some("50")); + assert_eq!( + get_q(&q, "shape").as_deref(), + Some(crate::SHAPE_BUDGET_ACCOUNTS_MINIMAL) + ); + } + + #[test] + fn extra_forwards_range_filters() { + let mut extra = BTreeMap::new(); + extra.insert("enacted_ba__gte".to_string(), "1000000".to_string()); + let opts = ListBudgetAccountsOptions::builder().extra(extra).build(); + let q = opts.to_query(); + assert_eq!(get_q(&q, "enacted_ba__gte").as_deref(), Some("1000000")); + } + + #[tokio::test] + async fn get_budget_account_empty_id_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client + .get_budget_account("", None) + .await + .expect_err("must error"); + match err { + Error::Validation { message, .. } => assert!(message.contains("id")), + other => panic!("expected Validation, got {other:?}"), + } + } + + #[tokio::test] + async fn get_budget_account_quarters_empty_id_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client + .get_budget_account_quarters("", None) + .await + .expect_err("must error"); + match err { + Error::Validation { message, .. } => assert!(message.contains("id")), + other => panic!("expected Validation, got {other:?}"), + } + } + + #[tokio::test] + async fn get_budget_account_recipients_empty_id_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client + .get_budget_account_recipients("", None) + .await + .expect_err("must error"); + match err { + Error::Validation { message, .. } => assert!(message.contains("id")), + other => panic!("expected Validation, got {other:?}"), + } + } +} diff --git a/crates/tango/src/resources/contracts.rs b/crates/tango/src/resources/contracts.rs index 05224eb..c07e761 100644 --- a/crates/tango/src/resources/contracts.rs +++ b/crates/tango/src/resources/contracts.rs @@ -1,9 +1,11 @@ //! `GET /api/contracts/` — list and stream federal contract records. use crate::client::Client; -use crate::error::Result; -use crate::internal::{apply_pagination, first_non_empty, push_opt}; +use crate::error::{Error, Result}; +use crate::internal::{apply_pagination, first_non_empty, push_opt, ListOptions}; use crate::pagination::{FetchFn, Page, PageStream}; +use crate::resources::agencies::urlencoding; +use crate::resources::entity_subresources::EntitySubresourceOptions; use crate::Record; use bon::Builder; use std::collections::BTreeMap; @@ -257,6 +259,58 @@ impl Client { Page::decode(&bytes) } + /// `GET /api/contracts/{key}/` — a single federal contract record. + pub async fn get_contract(&self, key: &str, opts: Option) -> Result { + if key.is_empty() { + return Err(Error::Validation { + message: "get_contract: key is required".into(), + response: None, + }); + } + let mut q = Vec::new(); + opts.unwrap_or_default().apply(&mut q); + let path = format!("/api/contracts/{}/", urlencoding(key)); + self.get_json::(&path, &q).await + } + + /// `GET /api/contracts/{key}/subawards/` — subawards reported against a + /// single prime contract. + pub async fn list_contract_subawards( + &self, + key: &str, + opts: Option, + ) -> Result> { + if key.is_empty() { + return Err(Error::Validation { + message: "list_contract_subawards: key is required".into(), + response: None, + }); + } + let q = opts.unwrap_or_default().to_query(); + let path = format!("/api/contracts/{}/subawards/", urlencoding(key)); + let bytes = self.get_bytes(&path, &q).await?; + Page::decode(&bytes) + } + + /// `GET /api/contracts/{key}/transactions/` — raw transaction history + /// backing a single contract. + pub async fn list_contract_transactions( + &self, + key: &str, + opts: Option, + ) -> Result> { + if key.is_empty() { + return Err(Error::Validation { + message: "list_contract_transactions: key is required".into(), + response: None, + }); + } + let q = opts.unwrap_or_default().to_query(); + let path = format!("/api/contracts/{}/transactions/", urlencoding(key)); + let bytes = self.get_bytes(&path, &q).await?; + Page::decode(&bytes) + } + /// Stream every federal contract record matching `opts`. The stream follows /// `?cursor=` (or `?page=` fallback) on the server's `next` URL. /// @@ -348,4 +402,40 @@ mod tests { Some(crate::SHAPE_CONTRACTS_MINIMAL) ); } + + #[tokio::test] + async fn get_contract_empty_key_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client.get_contract("", None).await.expect_err("must error"); + match err { + Error::Validation { message, .. } => assert!(message.contains("key")), + other => panic!("expected Validation, got {other:?}"), + } + } + + #[tokio::test] + async fn list_contract_subawards_empty_key_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client + .list_contract_subawards("", None) + .await + .expect_err("must error"); + match err { + Error::Validation { message, .. } => assert!(message.contains("key")), + other => panic!("expected Validation, got {other:?}"), + } + } + + #[tokio::test] + async fn list_contract_transactions_empty_key_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client + .list_contract_transactions("", None) + .await + .expect_err("must error"); + match err { + Error::Validation { message, .. } => assert!(message.contains("key")), + other => panic!("expected Validation, got {other:?}"), + } + } } diff --git a/crates/tango/src/resources/entities.rs b/crates/tango/src/resources/entities.rs index af182c0..8b69159 100644 --- a/crates/tango/src/resources/entities.rs +++ b/crates/tango/src/resources/entities.rs @@ -43,6 +43,10 @@ pub struct ListEntitiesOptions { /// CAGE code filter. #[builder(into)] pub cage_code: Option, + /// CAGE filter. Distinct API filter from [`cage_code`](Self::cage_code); + /// the server rejects setting both — use one or the other. + #[builder(into)] + pub cage: Option, /// NAICS code filter. #[builder(into)] pub naics: Option, @@ -93,6 +97,7 @@ impl ListEntitiesOptions { ); push_opt(&mut q, "search", self.search.as_deref()); push_opt(&mut q, "cage_code", self.cage_code.as_deref()); + push_opt(&mut q, "cage", self.cage.as_deref()); push_opt(&mut q, "naics", self.naics.as_deref()); push_opt(&mut q, "name", self.name.as_deref()); push_opt(&mut q, "psc", self.psc.as_deref()); @@ -201,6 +206,7 @@ mod tests { let opts = ListEntitiesOptions::builder() .search("Acme") .cage_code("1ABC5") + .cage("1ABC5") .naics("541512") .name("Acme Corp") .psc("D302") @@ -215,6 +221,7 @@ mod tests { let q = opts.to_query(); assert_eq!(get_q(&q, "search").as_deref(), Some("Acme")); assert_eq!(get_q(&q, "cage_code").as_deref(), Some("1ABC5")); + assert_eq!(get_q(&q, "cage").as_deref(), Some("1ABC5")); assert_eq!(get_q(&q, "naics").as_deref(), Some("541512")); assert_eq!(get_q(&q, "name").as_deref(), Some("Acme Corp")); assert_eq!(get_q(&q, "psc").as_deref(), Some("D302")); diff --git a/crates/tango/src/resources/entity_subresources.rs b/crates/tango/src/resources/entity_subresources.rs index eca4c7c..35d3162 100644 --- a/crates/tango/src/resources/entity_subresources.rs +++ b/crates/tango/src/resources/entity_subresources.rs @@ -200,6 +200,17 @@ impl Client { iterate_entity_subresource(self, uei.to_string(), "lcats", opts) } + /// `GET /api/entities/{uei}/budget-flows/` — funding-account budget flows + /// attributed to this entity. Returns a paginated list of funding-account + /// rows. + pub async fn get_entity_budget_flows( + &self, + uei: &str, + opts: Option, + ) -> Result> { + list_entity_subresource(self, uei, "budget-flows", opts.unwrap_or_default()).await + } + /// `GET /api/entities/{uei}/metrics/{months}/{period_grouping}/` — rolling /// windowed metrics for this entity. Mirrors the signature of the sibling /// SDKs (Node / Python / Go). @@ -351,6 +362,19 @@ mod tests { } } + #[tokio::test] + async fn get_entity_budget_flows_empty_uei_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client + .get_entity_budget_flows("", None) + .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"); diff --git a/crates/tango/src/resources/idv_subresources.rs b/crates/tango/src/resources/idv_subresources.rs index 9dae917..4120ce2 100644 --- a/crates/tango/src/resources/idv_subresources.rs +++ b/crates/tango/src/resources/idv_subresources.rs @@ -1,8 +1,8 @@ -//! IDV sub-resources: awards, child IDVs, transactions, summary, LCATs. +//! IDV sub-resources: awards, child IDVs, transactions, LCATs. //! //! Endpoints under `/api/idvs/{key}/…/` that share a common parameter shape. //! The Go SDK uses a mix of `ListIDVsOptions` (awards/child-idvs), -//! `ListOptions` (transactions/summary-awards), and `EntityLcatsOptions` +//! `ListOptions` (transactions), and `EntityLcatsOptions` //! (lcats); the Rust port consolidates these behind a single //! [`IdvSubresourceOptions`] (pagination + shape + ordering + search + joiner) //! since the surface server-side params for these endpoints are identical at @@ -20,7 +20,7 @@ use std::sync::Arc; /// Options shared by every IDV sub-resource list endpoint /// (`/api/idvs/{key}/awards/`, `/child-idvs/`, `/transactions/`, -/// `/summary/awards/`, `/lcats/`). +/// `/lcats/`). /// /// Carries pagination + shape + ordering + search + joiner. Use the `extra` /// field to forward filters not yet first-classed on this struct. @@ -139,37 +139,6 @@ impl Client { iterate_idv_subresource(self, key.to_string(), "transactions", opts) } - /// `GET /api/idvs/{identifier}/summary/` — summary roll-up for an IDV. - /// - /// Deprecated: the v1.0.0 server returns 404 for this endpoint. Retained - /// for parity with the other SDKs; migrate to [`Client::get_idv`] with the - /// comprehensive shape. - #[deprecated(note = "Deprecated upstream; use get_idv with the comprehensive shape")] - pub async fn get_idv_summary(&self, key: &str) -> Result { - if key.is_empty() { - return Err(Error::Validation { - message: "get_idv_summary: key is required".into(), - response: None, - }); - } - let path = format!("/api/idvs/{}/summary/", urlencoding(key)); - self.get_json::(&path, &[]).await - } - - /// `GET /api/idvs/{identifier}/summary/awards/` — awards belonging to an - /// IDV summary. - /// - /// Deprecated: the v1.0.0 server returns 404 for this endpoint. Retained - /// for parity with the other SDKs; migrate to [`Client::list_idv_awards`]. - #[deprecated(note = "Deprecated upstream; use list_idv_awards")] - pub async fn list_idv_summary_awards( - &self, - key: &str, - opts: IdvSubresourceOptions, - ) -> Result> { - list_idv_subresource(self, key, "summary/awards", opts).await - } - /// `GET /api/idvs/{key}/lcats/` — Labor Categories (LCATs) under an IDV. pub async fn list_idv_lcats( &self, @@ -292,15 +261,4 @@ mod tests { other => panic!("expected Validation, got {other:?}"), } } - - #[tokio::test] - #[allow(deprecated)] - async fn get_idv_summary_empty_key_returns_validation() { - let client = Client::builder().api_key("x").build().expect("build"); - let err = client.get_idv_summary("").await.expect_err("must error"); - match err { - Error::Validation { message, .. } => assert!(message.contains("key")), - other => panic!("expected Validation, got {other:?}"), - } - } } diff --git a/crates/tango/src/resources/mod.rs b/crates/tango/src/resources/mod.rs index fc7d863..88739d1 100644 --- a/crates/tango/src/resources/mod.rs +++ b/crates/tango/src/resources/mod.rs @@ -2,6 +2,7 @@ //! methods on [`Client`](crate::Client) and exports its `*Options` builders. pub(crate) mod agencies; +pub(crate) mod budget; pub(crate) mod contract_appeals; pub(crate) mod contracts; pub(crate) mod entities; @@ -28,6 +29,7 @@ pub use agencies::{ AgencyContractsOptions, GetAgencyOptions, ListAgenciesOptions, ListAgencyAwardingContractsOptions, ListAgencyFundingContractsOptions, }; +pub use budget::ListBudgetAccountsOptions; pub use contract_appeals::{GetContractAppealOptions, ListContractAppealsOptions}; pub use contracts::ListContractsOptions; pub use entities::{GetEntityOptions, ListEntitiesOptions}; diff --git a/crates/tango/src/resources/opportunities.rs b/crates/tango/src/resources/opportunities.rs index c8eeb2c..d931164 100644 --- a/crates/tango/src/resources/opportunities.rs +++ b/crates/tango/src/resources/opportunities.rs @@ -3,8 +3,9 @@ use crate::client::Client; use crate::error::{Error, Result}; -use crate::internal::{apply_pagination, push_opt, push_opt_bool, push_opt_u32}; +use crate::internal::{apply_pagination, push_opt, push_opt_bool, push_opt_u32, ListOptions}; use crate::pagination::{FetchFn, Page, PageStream}; +use crate::resources::agencies::urlencoding; use crate::Record; use bon::Builder; use std::collections::BTreeMap; @@ -445,6 +446,9 @@ pub struct ListGrantsOptions { /// CFDA number filter. #[builder(into)] pub cfda_number: Option, + /// Grant identifier filter (exact match on `grant_id`). + #[builder(into)] + pub grant_id: Option, /// Funding-categories filter (CSV). #[builder(into)] pub funding_categories: Option, @@ -496,6 +500,7 @@ impl ListGrantsOptions { push_opt(&mut q, "agency", self.agency.as_deref()); push_opt(&mut q, "applicant_types", self.applicant_types.as_deref()); push_opt(&mut q, "cfda_number", self.cfda_number.as_deref()); + push_opt(&mut q, "grant_id", self.grant_id.as_deref()); push_opt( &mut q, "funding_categories", @@ -661,6 +666,66 @@ impl Client { PageStream::new(self.clone(), fetch) } + /// `GET /api/opportunities/{opportunity_id}/` — a single opportunity. + pub async fn get_opportunity( + &self, + opportunity_id: &str, + opts: Option, + ) -> Result { + if opportunity_id.is_empty() { + return Err(Error::Validation { + message: "get_opportunity: opportunity_id is required".into(), + response: None, + }); + } + let mut q = Vec::new(); + opts.unwrap_or_default().apply(&mut q); + let path = format!("/api/opportunities/{}/", urlencoding(opportunity_id)); + self.get_json::(&path, &q).await + } + + /// `GET /api/notices/{notice_id}/` — a single notice. + pub async fn get_notice(&self, notice_id: &str, opts: Option) -> Result { + if notice_id.is_empty() { + return Err(Error::Validation { + message: "get_notice: notice_id is required".into(), + response: None, + }); + } + let mut q = Vec::new(); + opts.unwrap_or_default().apply(&mut q); + let path = format!("/api/notices/{}/", urlencoding(notice_id)); + self.get_json::(&path, &q).await + } + + /// `GET /api/forecasts/{id}/` — a single procurement forecast. + pub async fn get_forecast(&self, id: &str, opts: Option) -> Result { + if id.is_empty() { + return Err(Error::Validation { + message: "get_forecast: id is required".into(), + response: None, + }); + } + let mut q = Vec::new(); + opts.unwrap_or_default().apply(&mut q); + let path = format!("/api/forecasts/{}/", urlencoding(id)); + self.get_json::(&path, &q).await + } + + /// `GET /api/grants/{grant_id}/` — a single grant opportunity. + pub async fn get_grant(&self, grant_id: &str, opts: Option) -> Result { + if grant_id.is_empty() { + return Err(Error::Validation { + message: "get_grant: grant_id is required".into(), + response: None, + }); + } + let mut q = Vec::new(); + opts.unwrap_or_default().apply(&mut q); + let path = format!("/api/grants/{}/", urlencoding(grant_id)); + self.get_json::(&path, &q).await + } + /// `GET /api/opportunities/attachment-search/` — semantic search over /// the extracted text of opportunity attachments (SOWs, PWSs, J&As). /// @@ -790,6 +855,7 @@ mod tests { .agency("9700") .applicant_types("11") .cfda_number("10.001") + .grant_id("GRANT-123") .funding_categories("AR") .funding_instruments("G") .opportunity_number("OPP-001") @@ -802,12 +868,56 @@ mod tests { let q = opts.to_query(); assert_eq!(get_q(&q, "applicant_types").as_deref(), Some("11")); assert_eq!(get_q(&q, "cfda_number").as_deref(), Some("10.001")); + assert_eq!(get_q(&q, "grant_id").as_deref(), Some("GRANT-123")); assert_eq!(get_q(&q, "funding_categories").as_deref(), Some("AR")); assert_eq!(get_q(&q, "funding_instruments").as_deref(), Some("G")); assert_eq!(get_q(&q, "opportunity_number").as_deref(), Some("OPP-001")); assert_eq!(get_q(&q, "status").as_deref(), Some("posted")); } + #[tokio::test] + async fn get_grant_empty_key_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client.get_grant("", None).await.expect_err("must error"); + match err { + Error::Validation { message, .. } => assert!(message.contains("grant_id")), + other => panic!("expected Validation, got {other:?}"), + } + } + + #[tokio::test] + async fn get_opportunity_empty_key_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client + .get_opportunity("", None) + .await + .expect_err("must error"); + match err { + Error::Validation { message, .. } => assert!(message.contains("opportunity_id")), + other => panic!("expected Validation, got {other:?}"), + } + } + + #[tokio::test] + async fn get_forecast_empty_key_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client.get_forecast("", None).await.expect_err("must error"); + match err { + Error::Validation { message, .. } => assert!(message.contains("id")), + other => panic!("expected Validation, got {other:?}"), + } + } + + #[tokio::test] + async fn get_notice_empty_key_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client.get_notice("", None).await.expect_err("must error"); + match err { + Error::Validation { message, .. } => assert!(message.contains("notice_id")), + other => panic!("expected Validation, got {other:?}"), + } + } + #[test] fn attachment_search_emits_all_flags() { let opts = SearchOpportunityAttachmentsOptions::builder() diff --git a/crates/tango/src/resources/subawards.rs b/crates/tango/src/resources/subawards.rs index ae56cec..e8bbcb0 100644 --- a/crates/tango/src/resources/subawards.rs +++ b/crates/tango/src/resources/subawards.rs @@ -1,9 +1,10 @@ //! `GET /api/subawards/` — list and stream subaward records. use crate::client::Client; -use crate::error::Result; -use crate::internal::{apply_pagination, push_opt}; +use crate::error::{Error, Result}; +use crate::internal::{apply_pagination, push_opt, ListOptions}; use crate::pagination::{FetchFn, Page, PageStream}; +use crate::resources::agencies::urlencoding; use crate::Record; use bon::Builder; use std::collections::BTreeMap; @@ -112,6 +113,20 @@ impl Client { Page::decode(&bytes) } + /// `GET /api/subawards/{key}/` — a single subaward record. + pub async fn get_subaward(&self, key: &str, opts: Option) -> Result { + if key.is_empty() { + return Err(Error::Validation { + message: "get_subaward: key is required".into(), + response: None, + }); + } + let mut q = Vec::new(); + opts.unwrap_or_default().apply(&mut q); + let path = format!("/api/subawards/{}/", urlencoding(key)); + self.get_json::(&path, &q).await + } + /// Stream every subaward matching `opts`. pub fn iterate_subawards(&self, opts: ListSubawardsOptions) -> PageStream { let opts = Arc::new(opts); @@ -187,4 +202,14 @@ mod tests { let q = opts.to_query(); assert_eq!(get_q(&q, "x").as_deref(), Some("y")); } + + #[tokio::test] + async fn get_subaward_empty_key_returns_validation() { + let client = Client::builder().api_key("x").build().expect("build"); + let err = client.get_subaward("", None).await.expect_err("must error"); + match err { + Error::Validation { message, .. } => assert!(message.contains("key")), + other => panic!("expected Validation, got {other:?}"), + } + } } diff --git a/crates/tango/src/shapes.rs b/crates/tango/src/shapes.rs index c796792..d9a0e67 100644 --- a/crates/tango/src/shapes.rs +++ b/crates/tango/src/shapes.rs @@ -14,6 +14,13 @@ pub const DEFAULT_BASE_URL: &str = "https://tango.makegov.com"; pub const SHAPE_CONTRACTS_MINIMAL: &str = "key,piid,award_date,recipient(display_name),description,total_contract_value"; +/// Default shape for +/// [`Client::list_budget_accounts`](crate::Client::list_budget_accounts). +pub const SHAPE_BUDGET_ACCOUNTS_MINIMAL: &str = concat!( + "federal_account_symbol,fiscal_year,agency_name,enacted_ba,", + "obligated_total,contract_obligated,contract_share_of_obligated_capped" +); + /// Default shape for [`Client::list_entities`](crate::Client::list_entities). pub const SHAPE_ENTITIES_MINIMAL: &str = "uei,legal_business_name,cage_code,business_types"; diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index b1bbe39..edc97e0 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -31,8 +31,11 @@ Options: `ListAgenciesOptions`, `GetAgencyOptions`, `AgencyContractsOptions` (al | Method | Endpoint | Returns | | ------ | -------- | ------- | | `list_contracts(opts)` / `iterate_contracts(opts)` | `GET /api/contracts/` | `Page` / `PageStream` | +| `get_contract(key, opts)` | `GET /api/contracts/{key}/` | `Record` | +| `list_contract_subawards(key, opts)` | `GET /api/contracts/{key}/subawards/` | `Page` | +| `list_contract_transactions(key, opts)` | `GET /api/contracts/{key}/transactions/` | `Page` | -Options: `ListContractsOptions`. SDK-friendly filter aliases (`naics_code`, `psc_code`, `recipient_name`, `recipient_uei`, `set_aside_type`, `keyword`) map onto canonical API names. When both are set, the SDK alias wins (mirrors Node/Python). `sort`+`order` combine into `ordering` with `-` prefix for descending. +Options: `ListContractsOptions` (list), `ListOptions` (`get_contract`), `EntitySubresourceOptions` (sub-routes). SDK-friendly filter aliases (`naics_code`, `psc_code`, `recipient_name`, `recipient_uei`, `set_aside_type`, `keyword`) map onto canonical API names. When both are set, the SDK alias wins (mirrors Node/Python). `sort`+`order` combine into `ordering` with `-` prefix for descending. ### IDVs (`idvs.rs`, `idv_subresources.rs`) @@ -44,8 +47,6 @@ Options: `ListContractsOptions`. SDK-friendly filter aliases (`naics_code`, `psc | `list_idv_child_idvs(key, opts)` / `iterate_*` | `GET /api/idvs/{key}/child-idvs/` | `Page` / `PageStream` | | `list_idv_transactions(key, opts)` / `iterate_*` | `GET /api/idvs/{key}/transactions/` | `Page` / `PageStream` | | `list_idv_lcats(key, opts)` / `iterate_*` | `GET /api/idvs/{key}/lcats/` | `Page` / `PageStream` | -| `get_idv_summary(key)` *(deprecated)* | `GET /api/idvs/{key}/summary/` | `Record` | -| `list_idv_summary_awards(key, opts)` *(deprecated)* | `GET /api/idvs/{key}/summary/awards/` | `Page` | Options: `ListIDVsOptions`, `GetIDVOptions`, `IdvSubresourceOptions` (shared across the sub-resource list endpoints). @@ -61,9 +62,10 @@ Options: `ListIDVsOptions`, `GetIDVOptions`, `IdvSubresourceOptions` (shared acr | `list_entity_otidvs(uei, opts)` / `iterate_*` | `GET /api/entities/{uei}/otidvs/` | `Page` / `PageStream` | | `list_entity_subawards(uei, opts)` / `iterate_*` | `GET /api/entities/{uei}/subawards/` | `Page` / `PageStream` | | `list_entity_lcats(uei, opts)` / `iterate_*` | `GET /api/entities/{uei}/lcats/` | `Page` / `PageStream` | +| `get_entity_budget_flows(uei, opts)` | `GET /api/entities/{uei}/budget-flows/` | `Page` | | `get_entity_metrics(uei, months, period_grouping)` | `GET /api/entities/{uei}/metrics/{months}/{period_grouping}/` | `Record` | -Options: `ListEntitiesOptions`, `GetEntityOptions`, `EntitySubresourceOptions` (shared across sub-resource list endpoints). +Options: `ListEntitiesOptions`, `GetEntityOptions`, `EntitySubresourceOptions` (shared across sub-resource list endpoints). `ListEntitiesOptions` exposes both `cage` and `cage_code` as distinct typed filters; the server rejects setting both. ### Vehicles (`vehicles.rs`, `vehicle_subresources.rs`) @@ -81,12 +83,16 @@ Options: `ListVehiclesOptions`, `GetVehicleOptions`, `ListVehicleAwardeesOptions | Method | Endpoint | Returns | | ------ | -------- | ------- | | `list_opportunities(opts)` / `iterate_*` | `GET /api/opportunities/` | `Page` / `PageStream` | +| `get_opportunity(opportunity_id, opts)` | `GET /api/opportunities/{opportunity_id}/` | `Record` | | `list_notices(opts)` / `iterate_*` | `GET /api/notices/` | `Page` / `PageStream` | +| `get_notice(notice_id, opts)` | `GET /api/notices/{notice_id}/` | `Record` | | `list_forecasts(opts)` / `iterate_*` | `GET /api/forecasts/` | `Page` / `PageStream` | +| `get_forecast(id, opts)` | `GET /api/forecasts/{id}/` | `Record` | | `list_grants(opts)` / `iterate_*` | `GET /api/grants/` | `Page` / `PageStream` | +| `get_grant(grant_id, opts)` | `GET /api/grants/{grant_id}/` | `Record` | | `search_opportunity_attachments(opts)` | `GET /api/opportunities/attachment-search/` | `Page` | -Options: `ListOpportunitiesOptions`, `ListNoticesOptions`, `ListForecastsOptions`, `ListGrantsOptions`, `SearchOpportunityAttachmentsOptions`. The attachment-search method validates `q` non-empty client-side. +Options: `ListOpportunitiesOptions`, `ListNoticesOptions`, `ListForecastsOptions`, `ListGrantsOptions`, `SearchOpportunityAttachmentsOptions`. The singleton `get_*` methods take `Option`. The attachment-search method validates `q` non-empty client-side. `ListGrantsOptions` exposes a typed `grant_id` filter. ### OTAs / OTIDVs (`otas.rs`) @@ -105,8 +111,20 @@ Options: `ListOTAsOptions`, `GetOTAOptions`, `ListOTIDVsOptions`, `GetOTIDVOptio | Method | Endpoint | Returns | | ------ | -------- | ------- | | `list_subawards(opts)` / `iterate_subawards(opts)` | `GET /api/subawards/` | `Page` / `PageStream` | +| `get_subaward(key, opts)` | `GET /api/subawards/{key}/` | `Record` | -Options: `ListSubawardsOptions`. **Note**: the server rejects `id` and `amount` in subaward shapes; use `SHAPE_SUBAWARDS_MINIMAL` or a custom shape that avoids them. +Options: `ListSubawardsOptions` (list), `ListOptions` (`get_subaward`). **Note**: the server rejects `id` and `amount` in subaward shapes; use `SHAPE_SUBAWARDS_MINIMAL` or a custom shape that avoids them. + +### Budget (`budget.rs`) + +| Method | Endpoint | Returns | +| ------ | -------- | ------- | +| `list_budget_accounts(opts)` / `iterate_budget_accounts(opts)` | `GET /api/budget/accounts/` | `Page` / `PageStream` | +| `get_budget_account(id, opts)` | `GET /api/budget/accounts/{id}/` | `Record` | +| `get_budget_account_quarters(id, opts)` | `GET /api/budget/accounts/{id}/quarters/` | `Page` | +| `get_budget_account_recipients(id, opts)` | `GET /api/budget/accounts/{id}/recipients/` | `Page` | + +Options: `ListBudgetAccountsOptions` (list), `ListOptions` (`get_*`). The `BudgetAccount` schema is wide (~63 fields) and shape-driven; use `SHAPE_BUDGET_ACCOUNTS_MINIMAL` for a compact default. The full `__gte` / `__lte` numeric-range filters are reachable via the `extra` map. The `recipients` envelope carries extra keys (`federal_account_symbol`, `fiscal_year`) alongside the pagination fields. ### GSA eLibrary (`gsa.rs`) From 1fe3c84b662fc95611192903bbef4ee047f2640a Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Wed, 23 Sep 2026 10:28:37 -0500 Subject: [PATCH 2/5] feat: finish the 0.2.0 sync against the live API Adds the resources the sibling SDKs cover and this one did not: DIBBS (RFQs, RFPs, awards), SAM exclusions, and SBIR/STTR topics and solicitations, each with list/get/iterate, options builders naming every filter the API accepts, and a suggested shape. Gives the budget sub-routes and entity budget flows their own options (`tas`, `funding_organization_id`, `fiscal_year`), which the shared `ListOptions` could not send, and aligns `SHAPE_BUDGET_ACCOUNTS_MINIMAL` with the API's default shape. Names the list filters the API accepts that the SDK did not: `key` on the award families, `notice_id` / `department` / `office` on notices, `opportunity_id`, forecast `id`, `previous_uii`, SLED `verbose`, and the budget identity filters. Deprecates `search_opportunity_attachments`, whose endpoint the API retired (404 for every query). Corrects the `cage` doc (an alias of `cage_code`, not a distinct filter), the department-code doc (an integer), and the child-IDV path in the API reference. Adds httpmock wire tests for every new route, plus the integer department code on `get_department` and on `get_agency`'s nested department. Rewrites the Unreleased changelog into one section per heading, with the removed IDV summary methods under Breaking. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 36 +- README.md | 34 +- ROADMAP.md | 17 +- crates/tango/src/lib.rs | 13 +- crates/tango/src/resources/budget.rs | 146 ++- crates/tango/src/resources/contracts.rs | 14 + crates/tango/src/resources/dibbs.rs | 867 ++++++++++++++++++ crates/tango/src/resources/entities.rs | 4 +- .../src/resources/entity_subresources.rs | 60 +- crates/tango/src/resources/exclusions.rs | 399 ++++++++ crates/tango/src/resources/idvs.rs | 11 + crates/tango/src/resources/itdashboard.rs | 14 + crates/tango/src/resources/lookups.rs | 6 +- crates/tango/src/resources/mod.rs | 14 +- crates/tango/src/resources/opportunities.rs | 58 +- crates/tango/src/resources/otas.rs | 28 + crates/tango/src/resources/sbir.rs | 593 ++++++++++++ crates/tango/src/resources/sled.rs | 15 + crates/tango/src/shapes.rs | 50 +- crates/tango/tests/routes.rs | 368 ++++++++ docs/API_REFERENCE.md | 82 +- docs/SHAPES.md | 19 +- 22 files changed, 2756 insertions(+), 92 deletions(-) create mode 100644 crates/tango/src/resources/dibbs.rs create mode 100644 crates/tango/src/resources/exclusions.rs create mode 100644 crates/tango/src/resources/sbir.rs create mode 100644 crates/tango/tests/routes.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d5f77b..10fe112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,25 +7,24 @@ This project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] -Sync to Tango API v4.6.9. Pre-1.0 (SemVer 0.x): the removals below are breaking but ship without a deprecation cycle. +Pre-1.0 (SemVer 0.x): the removal under **Breaking** ships without a deprecation cycle. -### Added - -- **Budget surface** (`budget.rs`): `list_budget_accounts` / `iterate_budget_accounts` (`GET /api/budget/accounts/`), `get_budget_account` (`GET /api/budget/accounts/{id}/`), `get_budget_account_quarters` (`GET /api/budget/accounts/{id}/quarters/`), `get_budget_account_recipients` (`GET /api/budget/accounts/{id}/recipients/`). New `ListBudgetAccountsOptions` builder and `SHAPE_BUDGET_ACCOUNTS_MINIMAL` shape constant. -- Singleton detail GETs: `get_contract` (`GET /api/contracts/{key}/`), `get_opportunity`, `get_notice`, `get_forecast`, `get_grant`, `get_subaward`. -- Contract sub-routes: `list_contract_subawards` (`GET /api/contracts/{key}/subawards/`), `list_contract_transactions` (`GET /api/contracts/{key}/transactions/`). -- `get_entity_budget_flows` (`GET /api/entities/{uei}/budget-flows/`). -- `grant_id` typed filter on `ListGrantsOptions`. -- `cage` typed filter on `ListEntitiesOptions` (distinct from the existing `cage_code`; the server rejects setting both). - -### Removed +### `makegov-tango` -- **Breaking**: `get_idv_summary` and `list_idv_summary_awards`. These hit `/api/idvs/{key}/summary/` and `/api/idvs/{key}/summary/awards/`, which have never existed in the Tango API (the server returns 404). Use `get_idv` with a comprehensive shape and `list_idv_awards` respectively. +#### Breaking -### `makegov-tango` +- **Removed `get_idv_summary` and `list_idv_summary_awards`.** They called `/api/idvs/{key}/summary/` and `/api/idvs/{key}/summary/awards/`, which have never existed in the Tango API, so every call returned 404. Use `get_idv` with `SHAPE_IDVS_COMPREHENSIVE` and `list_idv_awards` instead. #### Added +- **Budget accounts** (`resources/budget.rs`): `list_budget_accounts` / `iterate_budget_accounts`, `get_budget_account`, `get_budget_account_quarters` and `get_budget_account_recipients`, with `ListBudgetAccountsOptions`, `BudgetAccountQuartersOptions` (`tas`), `BudgetAccountRecipientsOptions` (`funding_organization_id`) and `SHAPE_BUDGET_ACCOUNTS_MINIMAL`, which mirrors the API's default shape. The list endpoint rejects an unknown filter name with a 400, so the identity filters are typed fields and the numeric `__gte` / `__lte` range filters go through `extra`. `{id}` is the row's numeric `id`, not the federal account symbol. +- **`get_entity_budget_flows`** (`GET /api/entities/{uei}/budget-flows/`) with `EntityBudgetFlowsOptions` (pagination plus `fiscal_year`): the federal accounts that paid an entity, contract flows only. +- **DIBBS** (`resources/dibbs.rs`): `list_dibbs_rfqs` / `get_dibbs_rfq`, `list_dibbs_rfps` / `get_dibbs_rfp`, `list_dibbs_awards` / `get_dibbs_award`, each with an `iterate_*`, plus `SHAPE_DIBBS_RFQS_MINIMAL`, `SHAPE_DIBBS_RFPS_MINIMAL` and `SHAPE_DIBBS_AWARDS_MINIMAL`. Every filter the API accepts is a named field. RFQ and RFP `open` is derived at query time and is `Option`, so `Some(false)` reaches the server. An award row is one line item and `total_contract_price` is the order total repeated on each, so it must not be summed across rows. +- **Exclusions** (`resources/exclusions.rs`): `list_exclusions` / `get_exclusion` / `iterate_exclusions` and `SHAPE_EXCLUSIONS_MINIMAL`. `active` (in force today, derived at query time) and `delisted` (lifted or withdrawn by SAM, which is not the same as expiring) are `Option`. +- **SBIR / STTR** (`resources/sbir.rs`): `list_sbir_topics` / `get_sbir_topic` / `iterate_sbir_topics`, `list_sbir_solicitations` / `get_sbir_solicitation` / `iterate_sbir_solicitations`, `SHAPE_SBIR_TOPICS_MINIMAL` and `SHAPE_SBIR_SOLICITATIONS_MINIMAL`. `activity` is open/closed derived at query time; a topic with no close date to go on is `unknown`. +- **Singleton detail GETs:** `get_contract`, `get_opportunity`, `get_notice`, `get_forecast`, `get_grant` and `get_subaward`, each taking `Option` for `shape` / `flat`. +- **Contract sub-routes:** `list_contract_subawards` (`GET /api/contracts/{key}/subawards/`) and `list_contract_transactions` (`GET /api/contracts/{key}/transactions/`). +- **Filters the API accepts that the SDK did not name:** `key` on `ListContractsOptions`, `ListIDVsOptions`, `ListOTAsOptions`, `ListOTIDVsOptions` and `ListOTIDVAwardsOptions` (award keys, `|` for several); `grant_id` on `ListGrantsOptions`; `cage` on `ListEntitiesOptions` (an alias of `cage_code`; the API rejects a request that sets both); `notice_id`, `department` and `office` on `ListNoticesOptions`; `opportunity_id` on `ListOpportunitiesOptions`; `id` on `ListForecastsOptions`; `previous_uii` on `ListItdashboardOptions`; and `verbose` on `ListSledOpportunitiesOptions`, which adds `description` and `contact` to each list row. - **Contract appeals — CBCA and ASBCA decisions** (Tango API 4.26.0). `resources/contract_appeals.rs` adds `list_contract_appeals` / `get_contract_appeal` / `iterate_contract_appeals`, two `bon`-derived options builders (`ListContractAppealsOptions`, `GetContractAppealOptions`), the typed `models::ContractAppealRecord`, and `SHAPE_CONTRACT_APPEALS_MINIMAL`. Every filter the endpoint accepts is a named field: `search`, `board`, `docket`, `appellant`, `judge`, `decision_type`, `decision_date_after` / `_before`, `listed`, `document_id`, plus `ordering` over `decision_date` (the server's default is `-decision_date`), `appellant`, `first_listed_at` and `rank`. **These are Contract Disputes Act appeals, not bid protests.** An appeal is a dispute under a contract already awarded, decided by a board; a protest challenges the award itself and stays on `list_protests`. A company can appear in both corpora and nothing joins the two. @@ -50,10 +49,15 @@ Sync to Tango API v4.6.9. Pre-1.0 (SemVer 0.x): the removals below are breaking - **`SHAPE_SLED_OPPORTUNITIES_MINIMAL` and `SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE` now include `delisted_at`**, matching the API's own default shapes. It is when the portal stopped listing a solicitation before its deadline, and it is what `status_reason = "delisted"` refers to. +#### Deprecated + +- **`search_opportunity_attachments`.** The API retired `/api/opportunities/attachment-search/`: it returns 404 for every query and keeps the route only so a missing `q` still gets its 400. `list_opportunities` with `search` matches attachment text and returns a `snippet` for the hit. The method will be removed in a later release. + #### Fixed - **Protest enum values are documented in the casing the API returns.** `source_system` is lowercase (`gao`, `cofc`, `sba_oha`) and `outcome` is title-case (`Sustained`, `Denied`, …), not `"GAO"` / `"sustained"` as the rustdoc on `ListProtestsOptions` and `ProtestRecord` previously said. Filters were always case-insensitive, but code comparing returned values against the old examples would miss every match. The protests docs now also name SBA OHA as a source and list its extra outcomes (`Granted`, `Remanded`, `Reversed`, `Vacated`). - The `ListProtestsOptions::agency` rustdoc now describes what the filter accepts: a name, abbreviation or code, with `|` for multiple values. +- `get_department`'s rustdoc gave the department code as `"097"`. The API's department `code` is an integer (`97` for DoD) and comes back as a JSON number; the docs now say so, and a wire test pins that both `get_department` and the nested `department.code` on `get_agency` decode it. #### Documentation @@ -61,6 +65,12 @@ Sync to Tango API v4.6.9. Pre-1.0 (SemVer 0.x): the removals below are breaking - New **State & Local — SLED** section in `docs/API_REFERENCE.md` covering all six methods and both defaults that surprise people. - `docs/WEBHOOKS.md` alert semantics names `contract_appeal` / `alerts.contract_appeal.match` and says it overlaps neither `contract` nor the protest corpus. The SDK models `query_type` and `event_types` as free-form strings, so this is a documentation change rather than a new constant. - `docs/WEBHOOKS.md` alert semantics gained the date-lapse rule and its one exception. An exclusion or DIBBS solicitation reaching its date fires nothing, because open/closed is derived at query time — but `alerts.sled_opportunity.match` **does** fire on a closing, since SLED liveness is a stored column a fifteen-minute sweep writes. +- New **Budget**, **DIBBS**, **Exclusions** and **SBIR / STTR** sections in `docs/API_REFERENCE.md`, new rows in the README method table, and the 13 shape constants `docs/SHAPES.md` did not list. +- `docs/API_REFERENCE.md` gave the child-IDV route as `/api/idvs/{key}/child-idvs/`; the SDK always called the correct `/api/idvs/{key}/idvs/`. + +### `makegov-tango-webhooks` + +No changes; the version moves to 0.2.0 in lockstep with `makegov-tango`. ## [0.1.0] — 2026-05-15 diff --git a/README.md b/README.md index e4654ec..9dfa140 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,14 @@ Official, async-first Rust SDK for the [Tango API](https://tango.makegov.com) — federal contracts, IDVs, entities, opportunities, grants, vehicles, and more, with dynamic response shaping so you fetch only the fields you need. -> **In development — v0.1.0.** Not yet at sibling-SDK parity. The public API may shift before v1.0.0. Pin to a specific tag if you depend on this in production. +> **Pre-1.0 — v0.2.0.** The resource surface now tracks the sibling SDKs endpoint for endpoint, but the public API may still shift before v1.0.0. Pin to a specific version if you depend on this in production. -Sibling SDKs (`tango-node`, `tango-python`) are at v1.0.0; `tango-go` is at v0.1.0 with the same surface. This Rust port ships the full transport, error model, retry / rate-limit handling, webhook signing (in the separate `tango-webhooks` crate), and the ~75-method API surface. Same `0.x → 1.0` graduation logic as Go: the transport and types are stable; the surface stabilizes once it matches sibling parity. +The sibling SDKs are `tango-python`, `tango-node` and `tango-go`. This Rust port ships the full transport, error model, retry / rate-limit handling, webhook signing (in the separate `tango-webhooks` crate), and the resource-method surface. Same `0.x → 1.0` graduation logic as Go: the transport and types are stable; the surface stabilizes once it has been verified against the live API alongside the siblings. ## Features - **Async-first** — built on `tokio` + `reqwest`. One runtime, clean `futures::Stream`-based pagination. -- **Dynamic response shaping** — request exactly the fields you need via 21 built-in [`SHAPE_*`](https://docs.rs/makegov-tango/latest/tango/#constants) presets or a custom comma-separated field list. +- **Dynamic response shaping** — request exactly the fields you need via 34 built-in [`SHAPE_*`](https://docs.rs/makegov-tango/latest/tango/#constants) presets or a custom comma-separated field list. - **Typed errors** — single [`Error`](https://docs.rs/makegov-tango/latest/tango/enum.Error.html) enum with rich payload variants (`Auth`, `NotFound`, `Validation`, `RateLimit`, `Timeout`, `Api`, `Transport`, `Decode`, `Build`). Programmatic dispatch via `err.status()` and `err.is_retryable()`. - **Smart retries** — automatic backoff on 5xx / 408 / 429 / transport errors, honoring the server's `Retry-After` header. - **Compile-time-checked client builder** — via [`bon`](https://docs.rs/bon). Missing `api_key`? Won't compile. @@ -25,10 +25,10 @@ Sibling SDKs (`tango-node`, `tango-python`) are at v1.0.0; `tango-go` is at v0.1 ```toml [dependencies] -makegov-tango = "0.1" +makegov-tango = "0.2" # Optional: webhook signing for receivers -makegov-tango-webhooks = "0.1" +makegov-tango-webhooks = "0.2" ``` Crates publish under the `makegov-` prefix on crates.io; Rust imports stay short — `use tango::Client;` and `use tango_webhooks::verify;` — thanks to a `[lib] name` shim in each crate (same pattern as the `aws-sdk-*` family). @@ -116,7 +116,7 @@ let client = Client::builder() ### Dynamic response shaping -Every list and get endpoint accepts a `shape` parameter selecting which fields the API returns. The SDK ships 21 presets matching the Node, Python, and Go SDKs: +Every list and get endpoint accepts a `shape` parameter selecting which fields the API returns. The SDK ships 34 presets matching the Node, Python, and Go SDKs: ```rust,no_run # use tango::{Client, ListContractsOptions, SHAPE_CONTRACTS_MINIMAL}; @@ -241,28 +241,34 @@ match client.get_agency("9700", None).await { ## API methods -The SDK exposes ~75 methods on `Client` covering every endpoint in the sibling SDKs. The most-used 15 are listed here; the **full method-by-method reference lives in [`docs/API_REFERENCE.md`](docs/API_REFERENCE.md)**. +The SDK exposes a method on `Client` for every public endpoint the sibling SDKs cover. The main resources are listed here; the **full method-by-method reference lives in [`docs/API_REFERENCE.md`](docs/API_REFERENCE.md)**. | Resource | List | Get | Iterate | | ---- | ---- | ---- | ---- | | Agencies | `list_agencies` | `get_agency` *(typed: `AgencyRecord`)* | — | -| Contracts | `list_contracts` | — | `iterate_contracts` | +| Contracts | `list_contracts` | `get_contract` | `iterate_contracts` | | Entities | `list_entities` | `get_entity` | `iterate_entities` | | IDVs | `list_idvs` | `get_idv` | `iterate_idvs` | | Vehicles | `list_vehicles` | `get_vehicle` | `iterate_vehicles` | | OTAs | `list_otas` | `get_ota` | `iterate_otas` | | OTIDVs | `list_otidvs` | `get_otidv` | `iterate_otidvs` | -| Opportunities | `list_opportunities` | — | `iterate_opportunities` | -| Notices | `list_notices` | — | `iterate_notices` | -| Forecasts | `list_forecasts` | — | `iterate_forecasts` | -| Grants | `list_grants` | — | `iterate_grants` | +| Subawards | `list_subawards` | `get_subaward` | `iterate_subawards` | +| Opportunities | `list_opportunities` | `get_opportunity` | `iterate_opportunities` | +| Notices | `list_notices` | `get_notice` | `iterate_notices` | +| Forecasts | `list_forecasts` | `get_forecast` | `iterate_forecasts` | +| Grants | `list_grants` | `get_grant` | `iterate_grants` | | Protests | `list_protests` | `get_protest` *(typed: `ProtestRecord`)* | `iterate_protests` | | Contract appeals | `list_contract_appeals` | `get_contract_appeal` *(typed: `ContractAppealRecord`)* | `iterate_contract_appeals` | +| DIBBS (DLA) | `list_dibbs_rfqs` / `list_dibbs_rfps` / `list_dibbs_awards` | `get_dibbs_rfq` / `get_dibbs_rfp` / `get_dibbs_award` | `iterate_dibbs_*` | +| Exclusions | `list_exclusions` | `get_exclusion` | `iterate_exclusions` | +| SBIR / STTR | `list_sbir_topics` / `list_sbir_solicitations` | `get_sbir_topic` / `get_sbir_solicitation` | `iterate_sbir_*` | +| State & local (SLED) | `list_sled_opportunities` / `list_sled_forecasts` | `get_sled_opportunity` / `get_sled_forecast` | `iterate_sled_*` | +| Budget accounts | `list_budget_accounts` | `get_budget_account` | `iterate_budget_accounts` | | IT Dashboard | `list_itdashboard` | `get_itdashboard` | `iterate_itdashboard` | | NAICS / PSC | `list_naics` / `list_psc` | `get_naics` / `get_psc` | — | | Webhooks (CRUD) | `list_webhook_endpoints` / `list_webhook_alerts` | `get_` / `create_` / `update_` / `delete_` / `test_` | — | -Sub-resources and lookups: `list_entity_contracts` / `_idvs` / `_otas` / `_otidvs` / `_subawards` / `_lcats` / `get_entity_metrics`, `list_idv_awards` / `_child_idvs` / `_transactions` / `_lcats`, `list_agency_awarding_contracts` / `_funding_contracts`, `list_vehicle_awardees` / `_orders`, `list_otidv_awards`, `list_gsa_elibrary_contracts`, `list_business_types`, `list_offices`, `list_departments`, `list_mas_sins`, `list_assistance_listings`, `list_lcats` (dispatcher). Meta: `resolve`, `validate`, `get_version`, `list_api_keys`, `search_opportunity_attachments`. Metrics dispatcher: `list_metrics`. +Sub-resources and lookups: `list_contract_subawards` / `_transactions`, `list_entity_contracts` / `_idvs` / `_otas` / `_otidvs` / `_subawards` / `_lcats` / `get_entity_metrics` / `get_entity_budget_flows`, `get_budget_account_quarters` / `_recipients`, `list_sled_opportunity_revisions` / `get_sled_coverage`, `list_idv_awards` / `_child_idvs` / `_transactions` / `_lcats`, `list_agency_awarding_contracts` / `_funding_contracts`, `list_vehicle_awardees` / `_orders`, `list_otidv_awards`, `list_gsa_elibrary_contracts`, `list_business_types`, `list_offices`, `list_departments`, `list_mas_sins`, `list_assistance_listings`, `list_lcats` (dispatcher). Meta: `resolve`, `validate`, `get_version`, `list_api_keys`. `search_opportunity_attachments` is deprecated: the API retired that endpoint, and `list_opportunities` with `search` covers attachment text. Metrics dispatcher: `list_metrics`. See [`docs/API_REFERENCE.md`](docs/API_REFERENCE.md) for full signatures, filter fields, and quirks. @@ -288,7 +294,7 @@ In-repo guides: - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — crate layout, request lifecycle, design rationale - [`docs/CLIENT.md`](docs/CLIENT.md) — builder options, env vars, retry semantics, error model, rate-limit observability - [`docs/API_REFERENCE.md`](docs/API_REFERENCE.md) — method-by-method reference for every public method -- [`docs/SHAPES.md`](docs/SHAPES.md) — shape grammar, the 21 presets, `flat` / `flat_lists`, trade-offs +- [`docs/SHAPES.md`](docs/SHAPES.md) — shape grammar, the 34 presets, `flat` / `flat_lists`, trade-offs - [`docs/WEBHOOKS.md`](docs/WEBHOOKS.md) — receiving deliveries, CRUD methods, troubleshooting External: diff --git a/ROADMAP.md b/ROADMAP.md index 6862e3e..e373eda 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,14 +15,14 @@ This roadmap tracks the Rust SDK only. The goal is to stay closely aligned with - [X] Sub-resource walks for IDVs, entities, agencies, vehicles. - [X] OTAs / OTIDVs, GSA eLibrary, IT Dashboard, protests, LCATs. -## 0.2 (API sync to v4.6.9) +## 0.2 -- [X] Budget surface: `list_budget_accounts` / `iterate_budget_accounts`, `get_budget_account`, `get_budget_account_quarters`, `get_budget_account_recipients` (`/api/budget/accounts/`). -- [X] Singleton detail GETs: `get_contract`, `get_opportunity`, `get_notice`, `get_forecast`, `get_grant`, `get_subaward`. -- [X] Contract sub-routes: `list_contract_subawards`, `list_contract_transactions`. -- [X] `get_entity_budget_flows` (`/api/entities/{uei}/budget-flows/`). -- [X] `grant_id` filter on grants; `cage` filter on entities. -- [X] Removed fabricated `get_idv_summary` / `list_idv_summary_awards` (paths never existed upstream). +- [X] Budget accounts: `list_budget_accounts` / `iterate_budget_accounts`, `get_budget_account`, `get_budget_account_quarters`, `get_budget_account_recipients`, plus `get_entity_budget_flows`. +- [X] DIBBS (RFQs, RFPs, awards), SAM exclusions, and SBIR/STTR topics and solicitations. +- [X] Singleton detail GETs: `get_contract`, `get_opportunity`, `get_notice`, `get_forecast`, `get_grant`, `get_subaward`; contract sub-routes `list_contract_subawards` / `list_contract_transactions`. +- [X] Filter parity with the live API on every list endpoint the SDK covers. +- [X] State, local and education (SLED) procurement; contract appeals. +- [X] Removed the IDV summary methods, whose paths never existed upstream. ## Next @@ -30,7 +30,8 @@ This roadmap tracks the Rust SDK only. The goal is to stay closely aligned with - [ ] Optional `blocking` feature (sync facade over the async client, gated behind a Cargo feature). - [ ] Newtype identifiers for `Uei`, `EntityId`, `AgencyId`, etc. (compile-time safety, no string typo bugs). - [ ] Comprehensive integration tests against the live Tango API. -- [ ] Typed structs for remaining lookup/metric endpoints (NAICS, PSC, entity metrics, attachment search). +- [ ] Typed structs for remaining lookup/metric endpoints (NAICS, PSC, entity metrics). +- [ ] Remove the deprecated `search_opportunity_attachments` (the API retired its endpoint). ## Later (0.3) diff --git a/crates/tango/src/lib.rs b/crates/tango/src/lib.rs index b435054..25c6be2 100644 --- a/crates/tango/src/lib.rs +++ b/crates/tango/src/lib.rs @@ -120,11 +120,14 @@ pub use internal::ListOptions; pub use pagination::{Page, PageStream}; pub use shapes::{ DEFAULT_BASE_URL, SHAPE_BUDGET_ACCOUNTS_MINIMAL, SHAPE_CONTRACTS_MINIMAL, - SHAPE_CONTRACT_APPEALS_MINIMAL, SHAPE_ENTITIES_COMPREHENSIVE, SHAPE_ENTITIES_MINIMAL, - SHAPE_FORECASTS_MINIMAL, SHAPE_GRANTS_MINIMAL, SHAPE_GSA_ELIBRARY_CONTRACTS_MINIMAL, - SHAPE_IDVS_COMPREHENSIVE, SHAPE_IDVS_MINIMAL, SHAPE_ITDASHBOARD_INVESTMENTS_COMPREHENSIVE, - SHAPE_ITDASHBOARD_INVESTMENTS_MINIMAL, SHAPE_NOTICES_MINIMAL, SHAPE_OPPORTUNITIES_MINIMAL, - SHAPE_ORGANIZATIONS_MINIMAL, SHAPE_OTAS_MINIMAL, SHAPE_OTIDVS_MINIMAL, SHAPE_PROTESTS_MINIMAL, + SHAPE_CONTRACT_APPEALS_MINIMAL, SHAPE_DIBBS_AWARDS_MINIMAL, SHAPE_DIBBS_RFPS_MINIMAL, + SHAPE_DIBBS_RFQS_MINIMAL, SHAPE_ENTITIES_COMPREHENSIVE, SHAPE_ENTITIES_MINIMAL, + SHAPE_EXCLUSIONS_MINIMAL, SHAPE_FORECASTS_MINIMAL, SHAPE_GRANTS_MINIMAL, + SHAPE_GSA_ELIBRARY_CONTRACTS_MINIMAL, SHAPE_IDVS_COMPREHENSIVE, SHAPE_IDVS_MINIMAL, + SHAPE_ITDASHBOARD_INVESTMENTS_COMPREHENSIVE, SHAPE_ITDASHBOARD_INVESTMENTS_MINIMAL, + SHAPE_NOTICES_MINIMAL, SHAPE_OPPORTUNITIES_MINIMAL, SHAPE_ORGANIZATIONS_MINIMAL, + SHAPE_OTAS_MINIMAL, SHAPE_OTIDVS_MINIMAL, SHAPE_PROTESTS_MINIMAL, + SHAPE_SBIR_SOLICITATIONS_MINIMAL, SHAPE_SBIR_TOPICS_MINIMAL, SHAPE_SLED_FORECASTS_COMPREHENSIVE, SHAPE_SLED_FORECASTS_MINIMAL, SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE, SHAPE_SLED_OPPORTUNITIES_MINIMAL, SHAPE_SLED_REVISIONS_MINIMAL, SHAPE_SUBAWARDS_MINIMAL, SHAPE_VEHICLES_COMPREHENSIVE, diff --git a/crates/tango/src/resources/budget.rs b/crates/tango/src/resources/budget.rs index 9c48318..d9ee101 100644 --- a/crates/tango/src/resources/budget.rs +++ b/crates/tango/src/resources/budget.rs @@ -17,12 +17,10 @@ use bon::Builder; use std::collections::BTreeMap; use std::sync::Arc; -/// Options for [`Client::list_budget_accounts`] and -/// [`Client::iterate_budget_accounts`]. Mirrors `ListBudgetAccountsOptions` -/// in the Go SDK. +/// Options for [`Client::list_budget_accounts`] and [`Client::iterate_budget_accounts`]. /// -/// The full `__gte` / `__lte` range-filter set (the 26 numeric metrics on the -/// underlying FilterSet) is reachable via the [`extra`](Self::extra) map. +/// The API rejects an unknown filter name with a 400 rather than ignoring it. +/// The exact, `__gte` and `__lte` range filters on the numeric lifecycle and ratio fields (e.g. `enacted_ba__gte`), and the `__in` multi-value variants, are reachable via the [`extra`](Self::extra) map. #[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] #[non_exhaustive] pub struct ListBudgetAccountsOptions { @@ -61,9 +59,18 @@ pub struct ListBudgetAccountsOptions { /// Upper bound for `fiscal_year` (inclusive). #[builder(into)] pub fiscal_year_lte: Option, - /// Awarding/funding agency CGAC code filter (exact). + /// Agency code filter (exact). #[builder(into)] pub agency_code: Option, + /// Bureau name filter (exact). + #[builder(into)] + pub bureau_name: Option, + /// Case-insensitive substring match on the account title (sent as `account_title__icontains`). + #[builder(into)] + pub account_title: Option, + /// Budget subfunction code filter (exact). + #[builder(into)] + pub subfunction_code: Option, /// Bureau of Economic Analysis category filter (exact). #[builder(into)] pub bea_category: Option, @@ -104,6 +111,13 @@ impl ListBudgetAccountsOptions { 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, "agency_code", self.agency_code.as_deref()); + push_opt(&mut q, "bureau_name", self.bureau_name.as_deref()); + push_opt( + &mut q, + "account_title__icontains", + self.account_title.as_deref(), + ); + push_opt(&mut q, "subfunction_code", self.subfunction_code.as_deref()); push_opt(&mut q, "bea_category", self.bea_category.as_deref()); push_opt(&mut q, "on_off_budget", self.on_off_budget.as_deref()); push_opt(&mut q, "search", self.search.as_deref()); @@ -117,6 +131,58 @@ impl ListBudgetAccountsOptions { } } +/// Options for [`Client::get_budget_account_quarters`]. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct BudgetAccountQuartersOptions { + /// 1-based page number. + #[builder(into)] + pub page: Option, + /// Page size (default 25, server caps at 100). + #[builder(into)] + pub limit: Option, + /// Narrow to a single Treasury Account Symbol. Omit to get every TAS that rolls up under the federal account. + #[builder(into)] + pub tas: Option, +} + +impl BudgetAccountQuartersOptions { + fn to_query(&self) -> Vec<(String, String)> { + let mut q = Vec::new(); + apply_pagination(&mut q, self.page, self.limit, None, None, false, false); + push_opt(&mut q, "tas", self.tas.as_deref()); + q + } +} + +/// Options for [`Client::get_budget_account_recipients`]. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct BudgetAccountRecipientsOptions { + /// 1-based page number. + #[builder(into)] + pub page: Option, + /// Page size (default 25, server caps at 100). + #[builder(into)] + pub limit: Option, + /// Narrow to a single funding office (an organization UUID). + #[builder(into)] + pub funding_organization_id: Option, +} + +impl BudgetAccountRecipientsOptions { + fn to_query(&self) -> Vec<(String, String)> { + let mut q = Vec::new(); + apply_pagination(&mut q, self.page, self.limit, None, None, false, false); + push_opt( + &mut q, + "funding_organization_id", + self.funding_organization_id.as_deref(), + ); + q + } +} + impl Client { /// `GET /api/budget/accounts/` — one page of budget-account rollups. pub async fn list_budget_accounts( @@ -140,7 +206,7 @@ impl Client { PageStream::new(self.clone(), fetch) } - /// `GET /api/budget/accounts/{id}/` — a single budget-account rollup. + /// `GET /api/budget/accounts/{id}/` — a single budget-account rollup by its numeric `id` (from a list row's `id` field). pub async fn get_budget_account(&self, id: &str, opts: Option) -> Result { if id.is_empty() { return Err(Error::Validation { @@ -154,12 +220,14 @@ impl Client { self.get_json::(&path, &q).await } - /// `GET /api/budget/accounts/{id}/quarters/` — quarterly lifecycle detail - /// for a single account-year. + /// `GET /api/budget/accounts/{id}/quarters/` — one row per (TAS, quarter) of obligation and outlay flow for a single account-year. + /// + /// Coverage starts at FY2021; earlier years return an empty page. + /// The envelope also carries `federal_account_symbol` and `fiscal_year`, which [`Page`] does not surface. pub async fn get_budget_account_quarters( &self, id: &str, - opts: Option, + opts: Option, ) -> Result> { if id.is_empty() { return Err(Error::Validation { @@ -167,22 +235,20 @@ impl Client { response: None, }); } - let mut q = Vec::new(); - opts.unwrap_or_default().apply(&mut q); + let q = opts.unwrap_or_default().to_query(); let path = format!("/api/budget/accounts/{}/quarters/", urlencoding(id)); let bytes = self.get_bytes(&path, &q).await?; Page::decode(&bytes) } - /// `GET /api/budget/accounts/{id}/recipients/` — funding-office x recipient - /// contract-flow detail for a single account-year. The response envelope - /// carries extra keys (`federal_account_symbol`, `fiscal_year`) alongside - /// the standard pagination fields, so callers should navigate the returned - /// [`Record`] structure directly. + /// `GET /api/budget/accounts/{id}/recipients/` — funding-office x recipient contract flows for a single account-year, largest `contract_obligated` first. + /// + /// Contract flows only. Each row carries the resolved `funding_office` and `recipient`, plus a capped `contracts` list; a row that hits the cap sets `contracts_truncated`. + /// The envelope also carries `federal_account_symbol` and `fiscal_year`, which [`Page`] does not surface. pub async fn get_budget_account_recipients( &self, id: &str, - opts: Option, + opts: Option, ) -> Result> { if id.is_empty() { return Err(Error::Validation { @@ -190,8 +256,7 @@ impl Client { response: None, }); } - let mut q = Vec::new(); - opts.unwrap_or_default().apply(&mut q); + let q = opts.unwrap_or_default().to_query(); let path = format!("/api/budget/accounts/{}/recipients/", urlencoding(id)); let bytes = self.get_bytes(&path, &q).await?; Page::decode(&bytes) @@ -214,6 +279,9 @@ mod tests { .fiscal_year_gte("2020") .fiscal_year_lte("2025") .agency_code("9700") + .bureau_name("Operation and Maintenance") + .account_title("readiness") + .subfunction_code("051") .bea_category("discretionary") .on_off_budget("on") .search("operations") @@ -228,6 +296,15 @@ mod tests { assert_eq!(get_q(&q, "fiscal_year__gte").as_deref(), Some("2020")); assert_eq!(get_q(&q, "fiscal_year__lte").as_deref(), Some("2025")); assert_eq!(get_q(&q, "agency_code").as_deref(), Some("9700")); + assert_eq!( + get_q(&q, "bureau_name").as_deref(), + Some("Operation and Maintenance") + ); + assert_eq!( + get_q(&q, "account_title__icontains").as_deref(), + Some("readiness") + ); + assert_eq!(get_q(&q, "subfunction_code").as_deref(), Some("051")); assert_eq!(get_q(&q, "bea_category").as_deref(), Some("discretionary")); assert_eq!(get_q(&q, "on_off_budget").as_deref(), Some("on")); assert_eq!(get_q(&q, "search").as_deref(), Some("operations")); @@ -259,6 +336,35 @@ mod tests { assert_eq!(get_q(&q, "enacted_ba__gte").as_deref(), Some("1000000")); } + #[test] + fn quarters_options_emit_tas_and_pagination() { + let q = BudgetAccountQuartersOptions::builder() + .page(2u32) + .limit(50u32) + .tas("097-2020/2021-0100") + .build() + .to_query(); + assert_eq!(get_q(&q, "page").as_deref(), Some("2")); + assert_eq!(get_q(&q, "limit").as_deref(), Some("50")); + assert_eq!(get_q(&q, "tas").as_deref(), Some("097-2020/2021-0100")); + assert_eq!(q.len(), 3); + } + + #[test] + fn recipients_options_emit_funding_organization_id() { + let q = BudgetAccountRecipientsOptions::builder() + .funding_organization_id("0b7e4c1e-0000-4000-8000-000000000000") + .build() + .to_query(); + assert_eq!( + q, + vec![( + "funding_organization_id".to_string(), + "0b7e4c1e-0000-4000-8000-000000000000".to_string() + )] + ); + } + #[tokio::test] async fn get_budget_account_empty_id_returns_validation() { let client = Client::builder().api_key("x").build().expect("build"); diff --git a/crates/tango/src/resources/contracts.rs b/crates/tango/src/resources/contracts.rs index c07e761..507bf1c 100644 --- a/crates/tango/src/resources/contracts.rs +++ b/crates/tango/src/resources/contracts.rs @@ -152,6 +152,10 @@ pub struct ListContractsOptions { #[builder(into)] pub order: Option, + /// Filter by Tango award key (the detail-endpoint identifier). Supports multi-value OR via `|`. + #[builder(into)] + pub key: Option, + /// Escape hatch for filter keys not yet first-classed on this struct. #[builder(default)] pub extra: BTreeMap, @@ -196,6 +200,7 @@ impl ListContractsOptions { 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, "key", self.key.as_deref()); push_opt( &mut q, "solicitation_identifier", @@ -438,4 +443,13 @@ mod tests { other => panic!("expected Validation, got {other:?}"), } } + + #[test] + fn list_contracts_key_filter_emits() { + let q = ListContractsOptions::builder() + .key("K1|K2") + .build() + .to_query(); + assert_eq!(get_q(&q, "key").as_deref(), Some("K1|K2")); + } } diff --git a/crates/tango/src/resources/dibbs.rs b/crates/tango/src/resources/dibbs.rs new file mode 100644 index 0000000..766738d --- /dev/null +++ b/crates/tango/src/resources/dibbs.rs @@ -0,0 +1,867 @@ +//! `GET /api/dibbs/` — Defense Logistics Agency (DLA) DIBBS solicitations and awards: requests for quote (RFQs), requests for proposal (RFPs), and award line items. +//! +//! Every DIBBS record belongs to the Defense Logistics Agency. +//! Open/closed on RFQs and RFPs is derived at query time from the response date, so filter with `open` rather than comparing a returned `is_open` from an older page. + +use crate::client::Client; +use crate::error::{Error, Result}; +use crate::internal::{apply_pagination, push_opt, push_opt_bool}; +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; + +/// Options for [`Client::list_dibbs_rfqs`] and [`Client::iterate_dibbs_rfqs`]. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct ListDibbsRfqsOptions { + /// 1-based page number. + #[builder(into)] + pub page: Option, + /// Page size (server caps at 100). + #[builder(into)] + pub limit: Option, + /// Keyset cursor. + #[builder(into)] + pub cursor: Option, + /// Comma-separated field selector. Use [`SHAPE_DIBBS_RFQS_MINIMAL`](crate::SHAPE_DIBBS_RFQS_MINIMAL) or roll your own. + #[builder(into)] + pub shape: Option, + /// Collapse nested objects into dot-separated keys. + #[builder(default)] + pub flat: bool, + /// When [`flat`](Self::flat) is also true, flatten list-valued fields. + #[builder(default)] + pub flat_lists: bool, + + /// Whether the RFQ is still accepting quotes as of today (its `return_by_date` has not passed). Derived at query time; there is no stored flag. `Some(false)` reaches the server as a filter value. + pub open: Option, + /// National Stock Number. Supports OR via `|`. + #[builder(into)] + pub nsn: Option, + /// Manufacturer part number. Supports OR via `|`. + #[builder(into)] + pub part_number: Option, + /// DLA solicitation number (e.g. `SPE1C126Q0337`). + #[builder(into)] + pub solicitation: Option, + /// Purchase request number. Supports OR via `|`. + #[builder(into)] + pub purchase_request: Option, + /// Set-aside flag (`Y` or `N`). + #[builder(into)] + pub set_aside: Option, + /// DIBBS status code. Supports OR via `|`. + #[builder(into)] + pub status_code: Option, + /// The resolved organization's federal hierarchy key. + #[builder(into)] + pub organization: Option, + /// Quantity greater than or equal to. + #[builder(into)] + pub quantity_min: Option, + /// Quantity less than or equal to. + #[builder(into)] + pub quantity_max: Option, + /// Return-by date on or after (`YYYY-MM-DD`). + #[builder(into)] + pub return_by_date_after: Option, + /// Return-by date on or before (`YYYY-MM-DD`). + #[builder(into)] + pub return_by_date_before: Option, + /// Issue date on or after (`YYYY-MM-DD`). + #[builder(into)] + pub issue_date_after: Option, + /// Issue date on or before (`YYYY-MM-DD`). + #[builder(into)] + pub issue_date_before: Option, + /// Full-text search across nomenclature, NSN, part number, and solicitation number. + #[builder(into)] + pub search: Option, + /// One of `issue_date`, `return_by_date`, `quantity`, `modified`, each optionally `-` prefixed. + #[builder(into)] + pub ordering: Option, + + /// Escape hatch for filter keys not yet first-classed on this struct. + #[builder(default)] + pub extra: BTreeMap, +} + +impl ListDibbsRfqsOptions { + 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, + ); + push_opt_bool(&mut q, "open", self.open); + push_opt(&mut q, "nsn", self.nsn.as_deref()); + push_opt(&mut q, "part_number", self.part_number.as_deref()); + push_opt(&mut q, "solicitation", self.solicitation.as_deref()); + push_opt(&mut q, "purchase_request", self.purchase_request.as_deref()); + push_opt(&mut q, "set_aside", self.set_aside.as_deref()); + push_opt(&mut q, "status_code", self.status_code.as_deref()); + push_opt(&mut q, "organization", self.organization.as_deref()); + push_opt(&mut q, "quantity_min", self.quantity_min.as_deref()); + push_opt(&mut q, "quantity_max", self.quantity_max.as_deref()); + push_opt( + &mut q, + "return_by_date_after", + self.return_by_date_after.as_deref(), + ); + push_opt( + &mut q, + "return_by_date_before", + self.return_by_date_before.as_deref(), + ); + push_opt(&mut q, "issue_date_after", self.issue_date_after.as_deref()); + push_opt( + &mut q, + "issue_date_before", + self.issue_date_before.as_deref(), + ); + push_opt(&mut q, "search", self.search.as_deref()); + 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 + } +} + +/// Options for [`Client::list_dibbs_rfps`] and [`Client::iterate_dibbs_rfps`]. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct ListDibbsRfpsOptions { + /// 1-based page number. + #[builder(into)] + pub page: Option, + /// Page size (server caps at 100). + #[builder(into)] + pub limit: Option, + /// Keyset cursor. + #[builder(into)] + pub cursor: Option, + /// Comma-separated field selector. Use [`SHAPE_DIBBS_RFPS_MINIMAL`](crate::SHAPE_DIBBS_RFPS_MINIMAL) or roll your own. + #[builder(into)] + pub shape: Option, + /// Collapse nested objects into dot-separated keys. + #[builder(default)] + pub flat: bool, + /// When [`flat`](Self::flat) is also true, flatten list-valued fields. + #[builder(default)] + pub flat_lists: bool, + + /// Whether the solicitation is still accepting offers as of today (its `closes_date` has not passed). Derived at query time; there is no stored flag. `Some(false)` reaches the server as a filter value. + pub open: Option, + /// National Stock Number. Supports OR via `|`. + #[builder(into)] + pub nsn: Option, + /// Manufacturer part number. Supports OR via `|`. + #[builder(into)] + pub part_number: Option, + /// DLA solicitation number. + #[builder(into)] + pub solicitation: Option, + /// DLA buyer code. Supports OR via `|`. + #[builder(into)] + pub buyer_code: Option, + /// The resolved organization's federal hierarchy key. + #[builder(into)] + pub organization: Option, + /// Issued date on or after (`YYYY-MM-DD`). + #[builder(into)] + pub issued_date_after: Option, + /// Issued date on or before (`YYYY-MM-DD`). + #[builder(into)] + pub issued_date_before: Option, + /// Close date on or after (`YYYY-MM-DD`). + #[builder(into)] + pub closes_date_after: Option, + /// Close date on or before (`YYYY-MM-DD`). + #[builder(into)] + pub closes_date_before: Option, + /// Full-text search across nomenclature, NSN, part number, and solicitation number. + #[builder(into)] + pub search: Option, + /// One of `issued_date`, `closes_date`, `modified`, each optionally `-` prefixed. + #[builder(into)] + pub ordering: Option, + + /// Escape hatch for filter keys not yet first-classed on this struct. + #[builder(default)] + pub extra: BTreeMap, +} + +impl ListDibbsRfpsOptions { + 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, + ); + push_opt_bool(&mut q, "open", self.open); + push_opt(&mut q, "nsn", self.nsn.as_deref()); + push_opt(&mut q, "part_number", self.part_number.as_deref()); + push_opt(&mut q, "solicitation", self.solicitation.as_deref()); + push_opt(&mut q, "buyer_code", self.buyer_code.as_deref()); + push_opt(&mut q, "organization", self.organization.as_deref()); + push_opt( + &mut q, + "issued_date_after", + self.issued_date_after.as_deref(), + ); + push_opt( + &mut q, + "issued_date_before", + self.issued_date_before.as_deref(), + ); + push_opt( + &mut q, + "closes_date_after", + self.closes_date_after.as_deref(), + ); + push_opt( + &mut q, + "closes_date_before", + self.closes_date_before.as_deref(), + ); + push_opt(&mut q, "search", self.search.as_deref()); + 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 + } +} + +/// Options for [`Client::list_dibbs_awards`] and [`Client::iterate_dibbs_awards`]. +/// +/// Each row is one line item of an award. **`total_contract_price` is the order total repeated on every line item**, so never sum it across rows: deduplicate on `award_number` + `delivery_order_number` first. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct ListDibbsAwardsOptions { + /// 1-based page number. + #[builder(into)] + pub page: Option, + /// Page size (server caps at 100). + #[builder(into)] + pub limit: Option, + /// Keyset cursor. + #[builder(into)] + pub cursor: Option, + /// Comma-separated field selector. Use [`SHAPE_DIBBS_AWARDS_MINIMAL`](crate::SHAPE_DIBBS_AWARDS_MINIMAL) or roll your own. + #[builder(into)] + pub shape: Option, + /// Collapse nested objects into dot-separated keys. + #[builder(default)] + pub flat: bool, + /// When [`flat`](Self::flat) is also true, flatten list-valued fields. + #[builder(default)] + pub flat_lists: bool, + + /// National Stock Number. Supports OR via `|`. + #[builder(into)] + pub nsn: Option, + /// Manufacturer part number. Supports OR via `|`. + #[builder(into)] + pub part_number: Option, + /// DLA solicitation number. + #[builder(into)] + pub solicitation: Option, + /// DLA award (contract) number. Order-scoped: one award number spans every line item of the order. + #[builder(into)] + pub award_number: Option, + /// Delivery order number. Null on a basic award with no delivery order. + #[builder(into)] + pub delivery_order_number: Option, + /// Purchase request number. Supports OR via `|`. + #[builder(into)] + pub purchase_request: Option, + /// The awardee's CAGE code. Populated on every award row, whether or not it resolved to a Tango entity. + #[builder(into)] + pub awardee_cage: Option, + /// UEI of the resolved Tango entity. Set only when the awardee's CAGE code matched a registered entity, so an unresolved award never matches it; use [`awardee_cage`](Self::awardee_cage) to reach every award. + #[builder(into)] + pub entity: Option, + /// The resolved organization's federal hierarchy key. + #[builder(into)] + pub organization: Option, + /// Award date on or after (`YYYY-MM-DD`). + #[builder(into)] + pub award_date_after: Option, + /// Award date on or before (`YYYY-MM-DD`). + #[builder(into)] + pub award_date_before: Option, + /// Posted date on or after (`YYYY-MM-DD`). + #[builder(into)] + pub posted_date_after: Option, + /// Posted date on or before (`YYYY-MM-DD`). + #[builder(into)] + pub posted_date_before: Option, + /// Order total contract price greater than or equal to. + #[builder(into)] + pub total_contract_price_min: Option, + /// Order total contract price less than or equal to. + #[builder(into)] + pub total_contract_price_max: Option, + /// Full-text search across nomenclature, NSN, part number, and solicitation number. + #[builder(into)] + pub search: Option, + /// One of `award_date`, `posted_date`, `total_contract_price`, `modified`, each optionally `-` prefixed. + #[builder(into)] + pub ordering: Option, + + /// Escape hatch for filter keys not yet first-classed on this struct. + #[builder(default)] + pub extra: BTreeMap, +} + +impl ListDibbsAwardsOptions { + 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, + ); + push_opt(&mut q, "nsn", self.nsn.as_deref()); + push_opt(&mut q, "part_number", self.part_number.as_deref()); + push_opt(&mut q, "solicitation", self.solicitation.as_deref()); + push_opt(&mut q, "award_number", self.award_number.as_deref()); + push_opt( + &mut q, + "delivery_order_number", + self.delivery_order_number.as_deref(), + ); + push_opt(&mut q, "purchase_request", self.purchase_request.as_deref()); + push_opt(&mut q, "awardee_cage", self.awardee_cage.as_deref()); + push_opt(&mut q, "entity", self.entity.as_deref()); + push_opt(&mut q, "organization", self.organization.as_deref()); + push_opt(&mut q, "award_date_after", self.award_date_after.as_deref()); + push_opt( + &mut q, + "award_date_before", + self.award_date_before.as_deref(), + ); + push_opt( + &mut q, + "posted_date_after", + self.posted_date_after.as_deref(), + ); + push_opt( + &mut q, + "posted_date_before", + self.posted_date_before.as_deref(), + ); + push_opt( + &mut q, + "total_contract_price_min", + self.total_contract_price_min.as_deref(), + ); + push_opt( + &mut q, + "total_contract_price_max", + self.total_contract_price_max.as_deref(), + ); + push_opt(&mut q, "search", self.search.as_deref()); + 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 + } +} + +/// Options for [`Client::get_dibbs_rfq`], [`Client::get_dibbs_rfp`], [`Client::get_dibbs_award`]. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct GetDibbsOptions { + /// Shape selector. When empty, the server returns its default detail shape. + #[builder(into)] + pub shape: Option, + /// Flatten nested objects into dot-separated keys. + #[builder(default)] + pub flat: bool, + /// When `flat=true`, also flatten list-valued nested fields. + #[builder(default)] + pub flat_lists: bool, +} + +impl GetDibbsOptions { + pub(crate) fn to_query(&self) -> Vec<(String, String)> { + let mut q = Vec::new(); + push_opt(&mut q, "shape", self.shape.as_deref()); + if self.flat { + q.push(("flat".into(), "true".into())); + } + if self.flat_lists { + q.push(("flat_lists".into(), "true".into())); + } + q + } +} + +impl Client { + /// `GET /api/dibbs/rfqs/` — one page of DIBBS request-for-quote line records. + pub async fn list_dibbs_rfqs(&self, opts: ListDibbsRfqsOptions) -> Result> { + let q = opts.to_query(); + let bytes = self.get_bytes("/api/dibbs/rfqs/", &q).await?; + Page::decode(&bytes) + } + + /// `GET /api/dibbs/rfqs/{uuid}/` — a single DIBBS request-for-quote line. + pub async fn get_dibbs_rfq(&self, uuid: &str, opts: Option) -> Result { + if uuid.is_empty() { + return Err(Error::Validation { + message: "get_dibbs_rfq: uuid is required".into(), + response: None, + }); + } + let q = opts.unwrap_or_default().to_query(); + let path = format!("/api/dibbs/rfqs/{}/", urlencoding(uuid)); + self.get_json::(&path, &q).await + } + + /// Stream every DIBBS request-for-quote line matching `opts`. + pub fn iterate_dibbs_rfqs(&self, opts: ListDibbsRfqsOptions) -> PageStream { + let opts = Arc::new(opts); + let fetch: FetchFn = Box::new(move |client, page, cursor| { + let mut next = (*opts).clone(); + next.page = page; + next.cursor = cursor; + Box::pin(async move { client.list_dibbs_rfqs(next).await }) + }); + PageStream::new(self.clone(), fetch) + } + + /// `GET /api/dibbs/rfps/` — one page of DIBBS request-for-proposal records. + pub async fn list_dibbs_rfps(&self, opts: ListDibbsRfpsOptions) -> Result> { + let q = opts.to_query(); + let bytes = self.get_bytes("/api/dibbs/rfps/", &q).await?; + Page::decode(&bytes) + } + + /// `GET /api/dibbs/rfps/{uuid}/` — a single DIBBS request-for-proposal. + pub async fn get_dibbs_rfp(&self, uuid: &str, opts: Option) -> Result { + if uuid.is_empty() { + return Err(Error::Validation { + message: "get_dibbs_rfp: uuid is required".into(), + response: None, + }); + } + let q = opts.unwrap_or_default().to_query(); + let path = format!("/api/dibbs/rfps/{}/", urlencoding(uuid)); + self.get_json::(&path, &q).await + } + + /// Stream every DIBBS request-for-proposal matching `opts`. + pub fn iterate_dibbs_rfps(&self, opts: ListDibbsRfpsOptions) -> PageStream { + let opts = Arc::new(opts); + let fetch: FetchFn = Box::new(move |client, page, cursor| { + let mut next = (*opts).clone(); + next.page = page; + next.cursor = cursor; + Box::pin(async move { client.list_dibbs_rfps(next).await }) + }); + PageStream::new(self.clone(), fetch) + } + + /// `GET /api/dibbs/awards/` — one page of DIBBS award line item records. + pub async fn list_dibbs_awards(&self, opts: ListDibbsAwardsOptions) -> Result> { + let q = opts.to_query(); + let bytes = self.get_bytes("/api/dibbs/awards/", &q).await?; + Page::decode(&bytes) + } + + /// `GET /api/dibbs/awards/{uuid}/` — a single DIBBS award line item. + pub async fn get_dibbs_award( + &self, + uuid: &str, + opts: Option, + ) -> Result { + if uuid.is_empty() { + return Err(Error::Validation { + message: "get_dibbs_award: uuid is required".into(), + response: None, + }); + } + let q = opts.unwrap_or_default().to_query(); + let path = format!("/api/dibbs/awards/{}/", urlencoding(uuid)); + self.get_json::(&path, &q).await + } + + /// Stream every DIBBS award line item matching `opts`. + pub fn iterate_dibbs_awards(&self, opts: ListDibbsAwardsOptions) -> PageStream { + let opts = Arc::new(opts); + let fetch: FetchFn = Box::new(move |client, page, cursor| { + let mut next = (*opts).clone(); + next.page = page; + next.cursor = cursor; + Box::pin(async move { client.list_dibbs_awards(next).await }) + }); + PageStream::new(self.clone(), fetch) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn get_q(q: &[(String, String)], k: &str) -> Option { + q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone()) + } + + #[test] + fn list_dibbs_rfqs_all_filters_emit() { + let opts = ListDibbsRfqsOptions::builder() + .open(true) + .nsn("v-nsn") + .part_number("v-part_number") + .solicitation("v-solicitation") + .purchase_request("v-purchase_request") + .set_aside("v-set_aside") + .status_code("v-status_code") + .organization("v-organization") + .quantity_min("v-quantity_min") + .quantity_max("v-quantity_max") + .return_by_date_after("v-return_by_date_after") + .return_by_date_before("v-return_by_date_before") + .issue_date_after("v-issue_date_after") + .issue_date_before("v-issue_date_before") + .search("v-search") + .ordering("v-ordering") + .build(); + let q = opts.to_query(); + assert_eq!(get_q(&q, "open").as_deref(), Some("true")); + assert_eq!(get_q(&q, "nsn").as_deref(), Some("v-nsn")); + assert_eq!(get_q(&q, "part_number").as_deref(), Some("v-part_number")); + assert_eq!(get_q(&q, "solicitation").as_deref(), Some("v-solicitation")); + assert_eq!( + get_q(&q, "purchase_request").as_deref(), + Some("v-purchase_request") + ); + assert_eq!(get_q(&q, "set_aside").as_deref(), Some("v-set_aside")); + assert_eq!(get_q(&q, "status_code").as_deref(), Some("v-status_code")); + assert_eq!(get_q(&q, "organization").as_deref(), Some("v-organization")); + assert_eq!(get_q(&q, "quantity_min").as_deref(), Some("v-quantity_min")); + assert_eq!(get_q(&q, "quantity_max").as_deref(), Some("v-quantity_max")); + assert_eq!( + get_q(&q, "return_by_date_after").as_deref(), + Some("v-return_by_date_after") + ); + assert_eq!( + get_q(&q, "return_by_date_before").as_deref(), + Some("v-return_by_date_before") + ); + assert_eq!( + get_q(&q, "issue_date_after").as_deref(), + Some("v-issue_date_after") + ); + assert_eq!( + get_q(&q, "issue_date_before").as_deref(), + Some("v-issue_date_before") + ); + assert_eq!(get_q(&q, "search").as_deref(), Some("v-search")); + assert_eq!(get_q(&q, "ordering").as_deref(), Some("v-ordering")); + assert_eq!(q.len(), 16); + } + + #[test] + fn list_dibbs_rfqs_open_false_is_a_filter_not_an_absence() { + let opts = ListDibbsRfqsOptions::builder().open(false).build(); + assert_eq!(get_q(&opts.to_query(), "open").as_deref(), Some("false")); + } + + #[test] + fn list_dibbs_rfqs_zero_value_omitted() { + assert!(ListDibbsRfqsOptions::builder() + .build() + .to_query() + .is_empty()); + } + + #[test] + fn list_dibbs_rfqs_cursor_wins_over_page_and_shape_emits() { + let opts = ListDibbsRfqsOptions::builder() + .page(3u32) + .cursor("c0".to_string()) + .shape(crate::SHAPE_DIBBS_RFQS_MINIMAL) + .build(); + let q = opts.to_query(); + assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0")); + assert_eq!(get_q(&q, "page"), None); + assert_eq!( + get_q(&q, "shape").as_deref(), + Some(crate::SHAPE_DIBBS_RFQS_MINIMAL) + ); + } + + #[test] + fn list_dibbs_rfqs_extra_emits() { + let mut extra = BTreeMap::new(); + extra.insert("custom_x".to_string(), "xv".to_string()); + let q = ListDibbsRfqsOptions::builder() + .extra(extra) + .build() + .to_query(); + assert!(q.contains(&("custom_x".into(), "xv".into()))); + } + + #[tokio::test] + async fn get_dibbs_rfq_validates_empty_uuid() { + let client = Client::builder().api_key("x").build().expect("client"); + let err = client.get_dibbs_rfq("", None).await.unwrap_err(); + match err { + Error::Validation { message, .. } => { + assert!(message.contains("uuid is required")); + } + other => panic!("expected Validation, got {other:?}"), + } + } + + #[test] + fn list_dibbs_rfps_all_filters_emit() { + let opts = ListDibbsRfpsOptions::builder() + .open(true) + .nsn("v-nsn") + .part_number("v-part_number") + .solicitation("v-solicitation") + .buyer_code("v-buyer_code") + .organization("v-organization") + .issued_date_after("v-issued_date_after") + .issued_date_before("v-issued_date_before") + .closes_date_after("v-closes_date_after") + .closes_date_before("v-closes_date_before") + .search("v-search") + .ordering("v-ordering") + .build(); + let q = opts.to_query(); + assert_eq!(get_q(&q, "open").as_deref(), Some("true")); + assert_eq!(get_q(&q, "nsn").as_deref(), Some("v-nsn")); + assert_eq!(get_q(&q, "part_number").as_deref(), Some("v-part_number")); + assert_eq!(get_q(&q, "solicitation").as_deref(), Some("v-solicitation")); + assert_eq!(get_q(&q, "buyer_code").as_deref(), Some("v-buyer_code")); + assert_eq!(get_q(&q, "organization").as_deref(), Some("v-organization")); + assert_eq!( + get_q(&q, "issued_date_after").as_deref(), + Some("v-issued_date_after") + ); + assert_eq!( + get_q(&q, "issued_date_before").as_deref(), + Some("v-issued_date_before") + ); + assert_eq!( + get_q(&q, "closes_date_after").as_deref(), + Some("v-closes_date_after") + ); + assert_eq!( + get_q(&q, "closes_date_before").as_deref(), + Some("v-closes_date_before") + ); + assert_eq!(get_q(&q, "search").as_deref(), Some("v-search")); + assert_eq!(get_q(&q, "ordering").as_deref(), Some("v-ordering")); + assert_eq!(q.len(), 12); + } + + #[test] + fn list_dibbs_rfps_open_false_is_a_filter_not_an_absence() { + let opts = ListDibbsRfpsOptions::builder().open(false).build(); + assert_eq!(get_q(&opts.to_query(), "open").as_deref(), Some("false")); + } + + #[test] + fn list_dibbs_rfps_zero_value_omitted() { + assert!(ListDibbsRfpsOptions::builder() + .build() + .to_query() + .is_empty()); + } + + #[test] + fn list_dibbs_rfps_cursor_wins_over_page_and_shape_emits() { + let opts = ListDibbsRfpsOptions::builder() + .page(3u32) + .cursor("c0".to_string()) + .shape(crate::SHAPE_DIBBS_RFPS_MINIMAL) + .build(); + let q = opts.to_query(); + assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0")); + assert_eq!(get_q(&q, "page"), None); + assert_eq!( + get_q(&q, "shape").as_deref(), + Some(crate::SHAPE_DIBBS_RFPS_MINIMAL) + ); + } + + #[test] + fn list_dibbs_rfps_extra_emits() { + let mut extra = BTreeMap::new(); + extra.insert("custom_x".to_string(), "xv".to_string()); + let q = ListDibbsRfpsOptions::builder() + .extra(extra) + .build() + .to_query(); + assert!(q.contains(&("custom_x".into(), "xv".into()))); + } + + #[tokio::test] + async fn get_dibbs_rfp_validates_empty_uuid() { + let client = Client::builder().api_key("x").build().expect("client"); + let err = client.get_dibbs_rfp("", None).await.unwrap_err(); + match err { + Error::Validation { message, .. } => { + assert!(message.contains("uuid is required")); + } + other => panic!("expected Validation, got {other:?}"), + } + } + + #[test] + fn list_dibbs_awards_all_filters_emit() { + let opts = ListDibbsAwardsOptions::builder() + .nsn("v-nsn") + .part_number("v-part_number") + .solicitation("v-solicitation") + .award_number("v-award_number") + .delivery_order_number("v-delivery_order_number") + .purchase_request("v-purchase_request") + .awardee_cage("v-awardee_cage") + .entity("v-entity") + .organization("v-organization") + .award_date_after("v-award_date_after") + .award_date_before("v-award_date_before") + .posted_date_after("v-posted_date_after") + .posted_date_before("v-posted_date_before") + .total_contract_price_min("v-total_contract_price_min") + .total_contract_price_max("v-total_contract_price_max") + .search("v-search") + .ordering("v-ordering") + .build(); + let q = opts.to_query(); + assert_eq!(get_q(&q, "nsn").as_deref(), Some("v-nsn")); + assert_eq!(get_q(&q, "part_number").as_deref(), Some("v-part_number")); + assert_eq!(get_q(&q, "solicitation").as_deref(), Some("v-solicitation")); + assert_eq!(get_q(&q, "award_number").as_deref(), Some("v-award_number")); + assert_eq!( + get_q(&q, "delivery_order_number").as_deref(), + Some("v-delivery_order_number") + ); + assert_eq!( + get_q(&q, "purchase_request").as_deref(), + Some("v-purchase_request") + ); + assert_eq!(get_q(&q, "awardee_cage").as_deref(), Some("v-awardee_cage")); + assert_eq!(get_q(&q, "entity").as_deref(), Some("v-entity")); + assert_eq!(get_q(&q, "organization").as_deref(), Some("v-organization")); + assert_eq!( + get_q(&q, "award_date_after").as_deref(), + Some("v-award_date_after") + ); + assert_eq!( + get_q(&q, "award_date_before").as_deref(), + Some("v-award_date_before") + ); + assert_eq!( + get_q(&q, "posted_date_after").as_deref(), + Some("v-posted_date_after") + ); + assert_eq!( + get_q(&q, "posted_date_before").as_deref(), + Some("v-posted_date_before") + ); + assert_eq!( + get_q(&q, "total_contract_price_min").as_deref(), + Some("v-total_contract_price_min") + ); + assert_eq!( + get_q(&q, "total_contract_price_max").as_deref(), + Some("v-total_contract_price_max") + ); + assert_eq!(get_q(&q, "search").as_deref(), Some("v-search")); + assert_eq!(get_q(&q, "ordering").as_deref(), Some("v-ordering")); + assert_eq!(q.len(), 17); + } + + #[test] + fn list_dibbs_awards_zero_value_omitted() { + assert!(ListDibbsAwardsOptions::builder() + .build() + .to_query() + .is_empty()); + } + + #[test] + fn list_dibbs_awards_cursor_wins_over_page_and_shape_emits() { + let opts = ListDibbsAwardsOptions::builder() + .page(3u32) + .cursor("c0".to_string()) + .shape(crate::SHAPE_DIBBS_AWARDS_MINIMAL) + .build(); + let q = opts.to_query(); + assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0")); + assert_eq!(get_q(&q, "page"), None); + assert_eq!( + get_q(&q, "shape").as_deref(), + Some(crate::SHAPE_DIBBS_AWARDS_MINIMAL) + ); + } + + #[test] + fn list_dibbs_awards_extra_emits() { + let mut extra = BTreeMap::new(); + extra.insert("custom_x".to_string(), "xv".to_string()); + let q = ListDibbsAwardsOptions::builder() + .extra(extra) + .build() + .to_query(); + assert!(q.contains(&("custom_x".into(), "xv".into()))); + } + + #[tokio::test] + async fn get_dibbs_award_validates_empty_uuid() { + let client = Client::builder().api_key("x").build().expect("client"); + let err = client.get_dibbs_award("", None).await.unwrap_err(); + match err { + Error::Validation { message, .. } => { + assert!(message.contains("uuid is required")); + } + other => panic!("expected Validation, got {other:?}"), + } + } + + #[test] + fn get_options_emits() { + let q = GetDibbsOptions::builder() + .shape("a,b") + .flat(true) + .flat_lists(true) + .build() + .to_query(); + assert_eq!(get_q(&q, "shape").as_deref(), Some("a,b")); + assert_eq!(get_q(&q, "flat").as_deref(), Some("true")); + assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true")); + } +} diff --git a/crates/tango/src/resources/entities.rs b/crates/tango/src/resources/entities.rs index 8b69159..b86a173 100644 --- a/crates/tango/src/resources/entities.rs +++ b/crates/tango/src/resources/entities.rs @@ -43,8 +43,8 @@ pub struct ListEntitiesOptions { /// CAGE code filter. #[builder(into)] pub cage_code: Option, - /// CAGE filter. Distinct API filter from [`cage_code`](Self::cage_code); - /// the server rejects setting both — use one or the other. + /// Alias for [`cage_code`](Self::cage_code): both filter the same field. + /// The API rejects a request that sets both, so use one or the other. #[builder(into)] pub cage: Option, /// NAICS code filter. diff --git a/crates/tango/src/resources/entity_subresources.rs b/crates/tango/src/resources/entity_subresources.rs index 35d3162..08f84c3 100644 --- a/crates/tango/src/resources/entity_subresources.rs +++ b/crates/tango/src/resources/entity_subresources.rs @@ -19,6 +19,31 @@ use bon::Builder; use std::collections::BTreeMap; use std::sync::Arc; +/// Options for [`Client::get_entity_budget_flows`]. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct EntityBudgetFlowsOptions { + /// 1-based page number. + #[builder(into)] + pub page: Option, + /// Page size (default 25, server caps at 100). + #[builder(into)] + pub limit: Option, + /// Restrict to one fiscal year (e.g. `2024`). + pub fiscal_year: Option, +} + +impl EntityBudgetFlowsOptions { + fn to_query(&self) -> Vec<(String, String)> { + let mut q = Vec::new(); + apply_pagination(&mut q, self.page, self.limit, None, None, false, false); + if let Some(fy) = self.fiscal_year { + q.push(("fiscal_year".into(), fy.to_string())); + } + q + } +} + /// Options shared by every entity sub-resource list endpoint /// (`/api/entities/{uei}/contracts/`, `/idvs/`, `/otas/`, `/otidvs/`, /// `/subawards/`, `/lcats/`). @@ -200,15 +225,24 @@ impl Client { iterate_entity_subresource(self, uei.to_string(), "lcats", opts) } - /// `GET /api/entities/{uei}/budget-flows/` — funding-account budget flows - /// attributed to this entity. Returns a paginated list of funding-account - /// rows. + /// `GET /api/entities/{uei}/budget-flows/` — the federal accounts that paid this entity, largest `contract_obligated` first, each with its budget-account context. + /// + /// Contract flows only: grant and assistance flows are not in this index. pub async fn get_entity_budget_flows( &self, uei: &str, - opts: Option, + opts: Option, ) -> Result> { - list_entity_subresource(self, uei, "budget-flows", opts.unwrap_or_default()).await + if uei.is_empty() { + return Err(Error::Validation { + message: "get_entity_budget_flows: uei is required".into(), + response: None, + }); + } + let q = opts.unwrap_or_default().to_query(); + let path = format!("/api/entities/{}/budget-flows/", urlencoding(uei)); + let bytes = self.get_bytes(&path, &q).await?; + Page::decode(&bytes) } /// `GET /api/entities/{uei}/metrics/{months}/{period_grouping}/` — rolling @@ -362,6 +396,22 @@ mod tests { } } + #[test] + fn budget_flows_options_emit_fiscal_year() { + let q = EntityBudgetFlowsOptions::builder() + .fiscal_year(2024u32) + .limit(10u32) + .build() + .to_query(); + assert_eq!( + q, + vec![ + ("limit".to_string(), "10".to_string()), + ("fiscal_year".to_string(), "2024".to_string()) + ] + ); + } + #[tokio::test] async fn get_entity_budget_flows_empty_uei_returns_validation() { let client = Client::builder().api_key("x").build().expect("build"); diff --git a/crates/tango/src/resources/exclusions.rs b/crates/tango/src/resources/exclusions.rs new file mode 100644 index 0000000..f06ce64 --- /dev/null +++ b/crates/tango/src/resources/exclusions.rs @@ -0,0 +1,399 @@ +//! `GET /api/exclusions/` — SAM.gov exclusions (debarments, suspensions and other ineligibility records). +//! +//! Whether an exclusion is in force is derived at query time from its dates, so filter with `active` rather than trusting a stored flag. +//! Most exclusions are individuals and carry no UEI. + +use crate::client::Client; +use crate::error::{Error, Result}; +use crate::internal::{apply_pagination, push_opt, push_opt_bool}; +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; + +/// Options for [`Client::list_exclusions`] and [`Client::iterate_exclusions`]. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct ListExclusionsOptions { + /// 1-based page number. + #[builder(into)] + pub page: Option, + /// Page size (server caps at 100). + #[builder(into)] + pub limit: Option, + /// Keyset cursor. + #[builder(into)] + pub cursor: Option, + /// Comma-separated field selector. Use [`SHAPE_EXCLUSIONS_MINIMAL`](crate::SHAPE_EXCLUSIONS_MINIMAL) or roll your own. + #[builder(into)] + pub shape: Option, + /// Collapse nested objects into dot-separated keys. + #[builder(default)] + pub flat: bool, + /// When [`flat`](Self::flat) is also true, flatten list-valued fields. + #[builder(default)] + pub flat_lists: bool, + + /// Whether the exclusion is in force as of today: not delisted, already activated, and not yet terminated. Derived at query time. `Some(false)` reaches the server as a filter value. + pub active: Option, + /// Whether the exclusion has dropped out of the SAM catalog because SAM lifted or withdrew it. Distinct from a natural expiration, which leaves the record listed. + pub delisted: Option, + /// Classification type (e.g. `Firm|Individual`). + #[builder(into)] + pub classification_type: Option, + /// Exclusion type. Supports OR via `|`. + #[builder(into)] + pub exclusion_type: Option, + /// Exclusion program (e.g. `Reciprocal`). + #[builder(into)] + pub exclusion_program: Option, + /// Code of the agency that issued the exclusion. + #[builder(into)] + pub excluding_agency_code: Option, + /// Name of the agency that issued the exclusion. + #[builder(into)] + pub excluding_agency_name: Option, + /// SAM UEI on the exclusion record. + #[builder(into)] + pub uei: Option, + /// CAGE code. Supports OR via `|`. + #[builder(into)] + pub cage_code: Option, + /// National Provider Identifier. Supports OR via `|`. + #[builder(into)] + pub npi: Option, + /// UEI of the linked Tango entity. Set only when the exclusion's UEI matches a registered entity. + #[builder(into)] + pub entity_uei: Option, + /// Activation date on or after (`YYYY-MM-DD`). + #[builder(into)] + pub activate_date_after: Option, + /// Activation date on or before (`YYYY-MM-DD`). + #[builder(into)] + pub activate_date_before: Option, + /// Termination date on or after (`YYYY-MM-DD`). + #[builder(into)] + pub termination_date_after: Option, + /// Termination date on or before (`YYYY-MM-DD`). + #[builder(into)] + pub termination_date_before: Option, + /// SAM update date on or after (`YYYY-MM-DD`). + #[builder(into)] + pub update_date_after: Option, + /// SAM update date on or before (`YYYY-MM-DD`). + #[builder(into)] + pub update_date_before: Option, + /// Full-text search across names, excluding agency, and comments. + #[builder(into)] + pub search: Option, + /// One of `activate_date`, `termination_date`, `create_date`, `update_date`, `modified`, each optionally `-` prefixed. + #[builder(into)] + pub ordering: Option, + + /// Escape hatch for filter keys not yet first-classed on this struct. + #[builder(default)] + pub extra: BTreeMap, +} + +impl ListExclusionsOptions { + 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, + ); + push_opt_bool(&mut q, "active", self.active); + push_opt_bool(&mut q, "delisted", self.delisted); + push_opt( + &mut q, + "classification_type", + self.classification_type.as_deref(), + ); + push_opt(&mut q, "exclusion_type", self.exclusion_type.as_deref()); + push_opt( + &mut q, + "exclusion_program", + self.exclusion_program.as_deref(), + ); + push_opt( + &mut q, + "excluding_agency_code", + self.excluding_agency_code.as_deref(), + ); + push_opt( + &mut q, + "excluding_agency_name", + self.excluding_agency_name.as_deref(), + ); + push_opt(&mut q, "uei", self.uei.as_deref()); + push_opt(&mut q, "cage_code", self.cage_code.as_deref()); + push_opt(&mut q, "npi", self.npi.as_deref()); + push_opt(&mut q, "entity_uei", self.entity_uei.as_deref()); + push_opt( + &mut q, + "activate_date_after", + self.activate_date_after.as_deref(), + ); + push_opt( + &mut q, + "activate_date_before", + self.activate_date_before.as_deref(), + ); + push_opt( + &mut q, + "termination_date_after", + self.termination_date_after.as_deref(), + ); + push_opt( + &mut q, + "termination_date_before", + self.termination_date_before.as_deref(), + ); + push_opt( + &mut q, + "update_date_after", + self.update_date_after.as_deref(), + ); + push_opt( + &mut q, + "update_date_before", + self.update_date_before.as_deref(), + ); + push_opt(&mut q, "search", self.search.as_deref()); + 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 + } +} + +/// Options for [`Client::get_exclusion`]. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct GetExclusionOptions { + /// Shape selector. When empty, the server returns its default detail shape. + #[builder(into)] + pub shape: Option, + /// Flatten nested objects into dot-separated keys. + #[builder(default)] + pub flat: bool, + /// When `flat=true`, also flatten list-valued nested fields. + #[builder(default)] + pub flat_lists: bool, +} + +impl GetExclusionOptions { + pub(crate) fn to_query(&self) -> Vec<(String, String)> { + let mut q = Vec::new(); + push_opt(&mut q, "shape", self.shape.as_deref()); + if self.flat { + q.push(("flat".into(), "true".into())); + } + if self.flat_lists { + q.push(("flat_lists".into(), "true".into())); + } + q + } +} + +impl Client { + /// `GET /api/exclusions/` — one page of exclusion records. + pub async fn list_exclusions(&self, opts: ListExclusionsOptions) -> Result> { + let q = opts.to_query(); + let bytes = self.get_bytes("/api/exclusions/", &q).await?; + Page::decode(&bytes) + } + + /// `GET /api/exclusions/{exclusion_key}/` — a single exclusion. + pub async fn get_exclusion( + &self, + exclusion_key: &str, + opts: Option, + ) -> Result { + if exclusion_key.is_empty() { + return Err(Error::Validation { + message: "get_exclusion: exclusion_key is required".into(), + response: None, + }); + } + let q = opts.unwrap_or_default().to_query(); + let path = format!("/api/exclusions/{}/", urlencoding(exclusion_key)); + self.get_json::(&path, &q).await + } + + /// Stream every exclusion matching `opts`. + pub fn iterate_exclusions(&self, opts: ListExclusionsOptions) -> PageStream { + let opts = Arc::new(opts); + let fetch: FetchFn = Box::new(move |client, page, cursor| { + let mut next = (*opts).clone(); + next.page = page; + next.cursor = cursor; + Box::pin(async move { client.list_exclusions(next).await }) + }); + PageStream::new(self.clone(), fetch) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn get_q(q: &[(String, String)], k: &str) -> Option { + q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone()) + } + + #[test] + fn list_exclusions_all_filters_emit() { + let opts = ListExclusionsOptions::builder() + .active(true) + .delisted(true) + .classification_type("v-classification_type") + .exclusion_type("v-exclusion_type") + .exclusion_program("v-exclusion_program") + .excluding_agency_code("v-excluding_agency_code") + .excluding_agency_name("v-excluding_agency_name") + .uei("v-uei") + .cage_code("v-cage_code") + .npi("v-npi") + .entity_uei("v-entity_uei") + .activate_date_after("v-activate_date_after") + .activate_date_before("v-activate_date_before") + .termination_date_after("v-termination_date_after") + .termination_date_before("v-termination_date_before") + .update_date_after("v-update_date_after") + .update_date_before("v-update_date_before") + .search("v-search") + .ordering("v-ordering") + .build(); + let q = opts.to_query(); + assert_eq!(get_q(&q, "active").as_deref(), Some("true")); + assert_eq!(get_q(&q, "delisted").as_deref(), Some("true")); + assert_eq!( + get_q(&q, "classification_type").as_deref(), + Some("v-classification_type") + ); + assert_eq!( + get_q(&q, "exclusion_type").as_deref(), + Some("v-exclusion_type") + ); + assert_eq!( + get_q(&q, "exclusion_program").as_deref(), + Some("v-exclusion_program") + ); + assert_eq!( + get_q(&q, "excluding_agency_code").as_deref(), + Some("v-excluding_agency_code") + ); + assert_eq!( + get_q(&q, "excluding_agency_name").as_deref(), + Some("v-excluding_agency_name") + ); + assert_eq!(get_q(&q, "uei").as_deref(), Some("v-uei")); + assert_eq!(get_q(&q, "cage_code").as_deref(), Some("v-cage_code")); + assert_eq!(get_q(&q, "npi").as_deref(), Some("v-npi")); + assert_eq!(get_q(&q, "entity_uei").as_deref(), Some("v-entity_uei")); + assert_eq!( + get_q(&q, "activate_date_after").as_deref(), + Some("v-activate_date_after") + ); + assert_eq!( + get_q(&q, "activate_date_before").as_deref(), + Some("v-activate_date_before") + ); + assert_eq!( + get_q(&q, "termination_date_after").as_deref(), + Some("v-termination_date_after") + ); + assert_eq!( + get_q(&q, "termination_date_before").as_deref(), + Some("v-termination_date_before") + ); + assert_eq!( + get_q(&q, "update_date_after").as_deref(), + Some("v-update_date_after") + ); + assert_eq!( + get_q(&q, "update_date_before").as_deref(), + Some("v-update_date_before") + ); + assert_eq!(get_q(&q, "search").as_deref(), Some("v-search")); + assert_eq!(get_q(&q, "ordering").as_deref(), Some("v-ordering")); + assert_eq!(q.len(), 19); + } + + #[test] + fn list_exclusions_active_false_is_a_filter_not_an_absence() { + let opts = ListExclusionsOptions::builder().active(false).build(); + assert_eq!(get_q(&opts.to_query(), "active").as_deref(), Some("false")); + } + + #[test] + fn list_exclusions_zero_value_omitted() { + assert!(ListExclusionsOptions::builder() + .build() + .to_query() + .is_empty()); + } + + #[test] + fn list_exclusions_cursor_wins_over_page_and_shape_emits() { + let opts = ListExclusionsOptions::builder() + .page(3u32) + .cursor("c0".to_string()) + .shape(crate::SHAPE_EXCLUSIONS_MINIMAL) + .build(); + let q = opts.to_query(); + assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0")); + assert_eq!(get_q(&q, "page"), None); + assert_eq!( + get_q(&q, "shape").as_deref(), + Some(crate::SHAPE_EXCLUSIONS_MINIMAL) + ); + } + + #[test] + fn list_exclusions_extra_emits() { + let mut extra = BTreeMap::new(); + extra.insert("custom_x".to_string(), "xv".to_string()); + let q = ListExclusionsOptions::builder() + .extra(extra) + .build() + .to_query(); + assert!(q.contains(&("custom_x".into(), "xv".into()))); + } + + #[tokio::test] + async fn get_exclusion_validates_empty_exclusion_key() { + let client = Client::builder().api_key("x").build().expect("client"); + let err = client.get_exclusion("", None).await.unwrap_err(); + match err { + Error::Validation { message, .. } => { + assert!(message.contains("exclusion_key is required")); + } + other => panic!("expected Validation, got {other:?}"), + } + } + + #[test] + fn get_options_emits() { + let q = GetExclusionOptions::builder() + .shape("a,b") + .flat(true) + .flat_lists(true) + .build() + .to_query(); + assert_eq!(get_q(&q, "shape").as_deref(), Some("a,b")); + assert_eq!(get_q(&q, "flat").as_deref(), Some("true")); + assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true")); + } +} diff --git a/crates/tango/src/resources/idvs.rs b/crates/tango/src/resources/idvs.rs index 339e80f..fea3f6a 100644 --- a/crates/tango/src/resources/idvs.rs +++ b/crates/tango/src/resources/idvs.rs @@ -113,6 +113,10 @@ pub struct ListIDVsOptions { #[builder(into)] pub uei: Option, + /// Filter by Tango award key (the detail-endpoint identifier). Supports multi-value OR via `|`. + #[builder(into)] + pub key: Option, + /// Escape hatch for filter keys not yet first-classed on this struct. #[builder(default)] pub extra: BTreeMap, @@ -155,6 +159,7 @@ impl ListIDVsOptions { push_opt(&mut q, "naics", self.naics.as_deref()); push_opt(&mut q, "ordering", self.ordering.as_deref()); push_opt(&mut q, "piid", self.piid.as_deref()); + push_opt(&mut q, "key", self.key.as_deref()); push_opt( &mut q, "pop_start_date_gte", @@ -348,4 +353,10 @@ mod tests { other => panic!("expected Validation, got {other:?}"), } } + + #[test] + fn list_idvs_key_filter_emits() { + let q = ListIDVsOptions::builder().key("K1").build().to_query(); + assert_eq!(get_q(&q, "key").as_deref(), Some("K1")); + } } diff --git a/crates/tango/src/resources/itdashboard.rs b/crates/tango/src/resources/itdashboard.rs index ae4d3bc..01b1578 100644 --- a/crates/tango/src/resources/itdashboard.rs +++ b/crates/tango/src/resources/itdashboard.rs @@ -78,6 +78,10 @@ pub struct ListItdashboardOptions { #[builder(into)] pub performance_risk: Option, + /// Find the investment(s) that superseded a retired UII (e.g. `015-000000100`). + #[builder(into)] + pub previous_uii: Option, + /// Escape hatch for filter keys not yet first-classed on this struct. #[builder(default)] pub extra: BTreeMap, @@ -96,6 +100,7 @@ impl ListItdashboardOptions { self.flat_lists, ); push_opt(&mut q, "search", self.search.as_deref()); + push_opt(&mut q, "previous_uii", self.previous_uii.as_deref()); push_opt(&mut q, "agency_code", self.agency_code.as_deref()); push_opt(&mut q, "agency_name", self.agency_name.as_deref()); push_opt( @@ -285,4 +290,13 @@ mod tests { other => panic!("expected Validation, got {other:?}"), } } + + #[test] + fn list_itdashboard_previous_uii_emits() { + let q = ListItdashboardOptions::builder() + .previous_uii("015-000000100") + .build() + .to_query(); + assert_eq!(get_q(&q, "previous_uii").as_deref(), Some("015-000000100")); + } } diff --git a/crates/tango/src/resources/lookups.rs b/crates/tango/src/resources/lookups.rs index 585dc83..cc1b30e 100644 --- a/crates/tango/src/resources/lookups.rs +++ b/crates/tango/src/resources/lookups.rs @@ -816,8 +816,10 @@ impl Client { PageStream::new(self.clone(), fetch) } - /// `GET /api/departments/{code}/` — fetch a single department by code - /// (typically the CGAC department code, e.g. `"097"` for DoD). + /// `GET /api/departments/{code}/` — fetch a single department by code (e.g. `"97"` for DoD). + /// + /// The API's department `code` is an integer, so the returned record carries it as a JSON number (`97`), not a string. + /// An agency's nested `department.code` is the same integer, while the agency's own `code` is a string (`"9700"`). pub async fn get_department(&self, code: &str) -> Result { if code.is_empty() { return Err(Error::Validation { diff --git a/crates/tango/src/resources/mod.rs b/crates/tango/src/resources/mod.rs index 88739d1..cdd76d6 100644 --- a/crates/tango/src/resources/mod.rs +++ b/crates/tango/src/resources/mod.rs @@ -5,8 +5,10 @@ pub(crate) mod agencies; pub(crate) mod budget; pub(crate) mod contract_appeals; pub(crate) mod contracts; +pub(crate) mod dibbs; pub(crate) mod entities; pub(crate) mod entity_subresources; +pub(crate) mod exclusions; pub(crate) mod gsa; pub(crate) mod idv_subresources; pub(crate) mod idvs; @@ -19,6 +21,7 @@ pub(crate) mod opportunities; pub(crate) mod otas; pub(crate) mod protests; pub(crate) mod resolve_validate; +pub(crate) mod sbir; pub(crate) mod sled; pub(crate) mod subawards; pub(crate) mod vehicle_subresources; @@ -29,11 +32,17 @@ pub use agencies::{ AgencyContractsOptions, GetAgencyOptions, ListAgenciesOptions, ListAgencyAwardingContractsOptions, ListAgencyFundingContractsOptions, }; -pub use budget::ListBudgetAccountsOptions; +pub use budget::{ + BudgetAccountQuartersOptions, BudgetAccountRecipientsOptions, ListBudgetAccountsOptions, +}; pub use contract_appeals::{GetContractAppealOptions, ListContractAppealsOptions}; pub use contracts::ListContractsOptions; +pub use dibbs::{ + GetDibbsOptions, ListDibbsAwardsOptions, ListDibbsRfpsOptions, ListDibbsRfqsOptions, +}; pub use entities::{GetEntityOptions, ListEntitiesOptions}; -pub use entity_subresources::EntitySubresourceOptions; +pub use entity_subresources::{EntityBudgetFlowsOptions, EntitySubresourceOptions}; +pub use exclusions::{GetExclusionOptions, ListExclusionsOptions}; pub use gsa::{GetGsaElibraryContractOptions, ListGsaElibraryContractsOptions}; pub use idv_subresources::IdvSubresourceOptions; pub use idvs::{GetIDVOptions, ListIDVsOptions}; @@ -56,6 +65,7 @@ pub use otas::{ GetOTAOptions, GetOTIDVOptions, ListOTAsOptions, ListOTIDVAwardsOptions, ListOTIDVsOptions, }; pub use protests::{GetProtestOptions, ListProtestsOptions}; +pub use sbir::{GetSbirOptions, ListSbirSolicitationsOptions, ListSbirTopicsOptions}; pub use sled::{ GetSledOptions, ListSledForecastsOptions, ListSledOpportunitiesOptions, ListSledOpportunityRevisionsOptions, diff --git a/crates/tango/src/resources/opportunities.rs b/crates/tango/src/resources/opportunities.rs index d931164..3321bd4 100644 --- a/crates/tango/src/resources/opportunities.rs +++ b/crates/tango/src/resources/opportunities.rs @@ -91,6 +91,10 @@ pub struct ListOpportunitiesOptions { #[builder(into)] pub solicitation_number: Option, + /// Opportunity ID filter. + #[builder(into)] + pub opportunity_id: Option, + /// Escape hatch for filter keys not yet first-classed on this struct. #[builder(default)] pub extra: BTreeMap, @@ -150,6 +154,7 @@ impl ListOpportunitiesOptions { self.response_deadline_before.as_deref(), ); push_opt(&mut q, "search", self.search.as_deref()); + push_opt(&mut q, "opportunity_id", self.opportunity_id.as_deref()); push_opt(&mut q, "set_aside", self.set_aside.as_deref()); push_opt( &mut q, @@ -232,6 +237,18 @@ pub struct ListNoticesOptions { #[builder(into)] pub solicitation_number: Option, + /// Notice ID filter. + #[builder(into)] + pub notice_id: Option, + + /// Department filter: a name, abbreviation or code, with `|` for multiple values. + #[builder(into)] + pub department: Option, + + /// Contracting office filter: a name, abbreviation or code, with `|` for multiple values. + #[builder(into)] + pub office: Option, + /// Escape hatch for filter keys not yet first-classed on this struct. #[builder(default)] pub extra: BTreeMap, @@ -275,6 +292,9 @@ impl ListNoticesOptions { self.response_deadline_before.as_deref(), ); push_opt(&mut q, "search", self.search.as_deref()); + push_opt(&mut q, "office", self.office.as_deref()); + push_opt(&mut q, "department", self.department.as_deref()); + push_opt(&mut q, "notice_id", self.notice_id.as_deref()); push_opt(&mut q, "set_aside", self.set_aside.as_deref()); push_opt( &mut q, @@ -361,6 +381,10 @@ pub struct ListForecastsOptions { #[builder(into)] pub status: Option, + /// Forecast ID filter. + #[builder(into)] + pub id: Option, + /// Escape hatch for filter keys not yet first-classed on this struct. #[builder(default)] pub extra: BTreeMap, @@ -398,6 +422,7 @@ impl ListForecastsOptions { ); push_opt(&mut q, "ordering", self.ordering.as_deref()); push_opt(&mut q, "search", self.search.as_deref()); + push_opt(&mut q, "id", self.id.as_deref()); push_opt(&mut q, "source_system", self.source_system.as_deref()); push_opt(&mut q, "status", self.status.as_deref()); for (k, v) in &self.extra { @@ -552,8 +577,7 @@ impl ListGrantsOptions { // Opportunity attachment search // --------------------------------------------------------------------------- -/// Options for [`Client::search_opportunity_attachments`] — semantic search -/// over the extracted text of opportunity attachments (SOWs, PWSs, J&As). +/// Options for [`Client::search_opportunity_attachments`], which targets a retired endpoint. /// /// `q` is required; an empty `q` causes the call to return /// [`Error::Validation`] before any network request. @@ -726,10 +750,15 @@ impl Client { self.get_json::(&path, &q).await } - /// `GET /api/opportunities/attachment-search/` — semantic search over - /// the extracted text of opportunity attachments (SOWs, PWSs, J&As). + /// `GET /api/opportunities/attachment-search/` — retired semantic search over opportunity attachments. /// + /// The API retired this endpoint: it now returns 404 for every query, and keeps the route only so a missing `q` still gets its 400. + /// Search attachment text through the `search` filter on [`Client::list_opportunities`] instead. /// Returns [`Error::Validation`] when `opts.q` is missing or empty. + #[deprecated( + since = "0.2.0", + note = "the API retired /api/opportunities/attachment-search/ and returns 404 for every query; use list_opportunities with `search`" + )] pub async fn search_opportunity_attachments( &self, opts: SearchOpportunityAttachmentsOptions, @@ -954,6 +983,7 @@ mod tests { } #[tokio::test] + #[allow(deprecated)] async fn search_opportunity_attachments_empty_q_returns_validation() { let client = Client::builder().api_key("x").build().expect("build"); let err = client @@ -967,4 +997,24 @@ mod tests { other => panic!("expected Validation, got {other:?}"), } } + + #[test] + fn identifier_and_org_filters_emit() { + let q = ListNoticesOptions::builder() + .notice_id("n1") + .department("DOD") + .office("W912") + .build() + .to_query(); + assert_eq!(get_q(&q, "notice_id").as_deref(), Some("n1")); + assert_eq!(get_q(&q, "department").as_deref(), Some("DOD")); + assert_eq!(get_q(&q, "office").as_deref(), Some("W912")); + let q = ListOpportunitiesOptions::builder() + .opportunity_id("o1") + .build() + .to_query(); + assert_eq!(get_q(&q, "opportunity_id").as_deref(), Some("o1")); + let q = ListForecastsOptions::builder().id("42").build().to_query(); + assert_eq!(get_q(&q, "id").as_deref(), Some("42")); + } } diff --git a/crates/tango/src/resources/otas.rs b/crates/tango/src/resources/otas.rs index 42923fc..0234bfc 100644 --- a/crates/tango/src/resources/otas.rs +++ b/crates/tango/src/resources/otas.rs @@ -117,6 +117,10 @@ pub struct ListOTAsOptions { #[builder(into)] pub ordering: Option, + /// Filter by Tango award key (the detail-endpoint identifier). Supports multi-value OR via `|`. + #[builder(into)] + pub key: Option, + /// Escape hatch for filter keys not yet first-classed on this struct. #[builder(default)] pub extra: BTreeMap, @@ -138,6 +142,7 @@ impl ListOTAsOptions { 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, "key", self.key.as_deref()); push_opt(&mut q, "recipient", self.recipient.as_deref()); push_opt(&mut q, "uei", self.uei.as_deref()); push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref()); @@ -302,6 +307,10 @@ pub struct ListOTIDVsOptions { #[builder(into)] pub ordering: Option, + /// Filter by Tango award key (the detail-endpoint identifier). Supports multi-value OR via `|`. + #[builder(into)] + pub key: Option, + /// Escape hatch for filter keys not yet first-classed. #[builder(default)] pub extra: BTreeMap, @@ -323,6 +332,7 @@ impl ListOTIDVsOptions { 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, "key", self.key.as_deref()); push_opt(&mut q, "recipient", self.recipient.as_deref()); push_opt(&mut q, "uei", self.uei.as_deref()); push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref()); @@ -486,6 +496,10 @@ pub struct ListOTIDVAwardsOptions { #[builder(into)] pub ordering: Option, + /// Filter by Tango award key (the detail-endpoint identifier). Supports multi-value OR via `|`. + #[builder(into)] + pub key: Option, + /// Escape hatch for filter keys not yet first-classed. #[builder(default)] pub extra: BTreeMap, @@ -507,6 +521,7 @@ impl ListOTIDVAwardsOptions { 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, "key", self.key.as_deref()); push_opt(&mut q, "recipient", self.recipient.as_deref()); push_opt(&mut q, "uei", self.uei.as_deref()); push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref()); @@ -811,4 +826,17 @@ mod tests { other => panic!("expected Validation, got {other:?}"), } } + + #[test] + fn ota_family_key_filter_emits() { + let q = ListOTAsOptions::builder().key("K1").build().to_query(); + assert_eq!(get_q(&q, "key").as_deref(), Some("K1")); + let q = ListOTIDVsOptions::builder().key("K2").build().to_query(); + assert_eq!(get_q(&q, "key").as_deref(), Some("K2")); + let q = ListOTIDVAwardsOptions::builder() + .key("K3") + .build() + .to_query(); + assert_eq!(get_q(&q, "key").as_deref(), Some("K3")); + } } diff --git a/crates/tango/src/resources/sbir.rs b/crates/tango/src/resources/sbir.rs new file mode 100644 index 0000000..40bfa05 --- /dev/null +++ b/crates/tango/src/resources/sbir.rs @@ -0,0 +1,593 @@ +//! `GET /api/sbir/` — SBIR/STTR topics and DoD DSIP solicitation cycles. +//! +//! `activity` (open/closed) is derived at query time from the close or end date; there is no stored flag. + +use crate::client::Client; +use crate::error::{Error, Result}; +use crate::internal::{apply_pagination, push_opt, push_opt_bool}; +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; + +/// Options for [`Client::list_sbir_topics`] and [`Client::iterate_sbir_topics`]. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct ListSbirTopicsOptions { + /// 1-based page number. + #[builder(into)] + pub page: Option, + /// Page size (server caps at 100). + #[builder(into)] + pub limit: Option, + /// Keyset cursor. + #[builder(into)] + pub cursor: Option, + /// Comma-separated field selector. Use [`SHAPE_SBIR_TOPICS_MINIMAL`](crate::SHAPE_SBIR_TOPICS_MINIMAL) or roll your own. + #[builder(into)] + pub shape: Option, + /// Collapse nested objects into dot-separated keys. + #[builder(default)] + pub flat: bool, + /// When [`flat`](Self::flat) is also true, flatten list-valued fields. + #[builder(default)] + pub flat_lists: bool, + + /// Whether the topic is accepting proposals today: `open`, `closed`, or `unknown` (no close date and no parent solicitation to inherit one from). + #[builder(into)] + pub activity: Option, + /// Partial, case-insensitive match on the raw agency text (e.g. `DOD`, `NIH`). Not organization-resolved, so variant spellings do not normalize. + #[builder(into)] + pub agency: Option, + /// Topic number. + #[builder(into)] + pub topic_number: Option, + /// Parent solicitation number. + #[builder(into)] + pub solicitation_number: Option, + /// Solicitation year. + #[builder(into)] + pub year: Option, + /// Document source. + #[builder(into)] + pub doc_source: Option, + /// Close date on or after (`YYYY-MM-DD`). + #[builder(into)] + pub close_date_after: Option, + /// Close date on or before (`YYYY-MM-DD`). + #[builder(into)] + pub close_date_before: Option, + /// Open date on or after (`YYYY-MM-DD`). + #[builder(into)] + pub open_date_after: Option, + /// Open date on or before (`YYYY-MM-DD`). + #[builder(into)] + pub open_date_before: Option, + /// Release date on or after (`YYYY-MM-DD`). + #[builder(into)] + pub release_date_after: Option, + /// Release date on or before (`YYYY-MM-DD`). + #[builder(into)] + pub release_date_before: Option, + /// Case-insensitive substring match across title, description, and topic number. + #[builder(into)] + pub search: Option, + /// One of `activity`, `close_date`, `open_date`, `release_date`, `year`, `modified`, each optionally `-` prefixed. + #[builder(into)] + pub ordering: Option, + + /// Escape hatch for filter keys not yet first-classed on this struct. + #[builder(default)] + pub extra: BTreeMap, +} + +impl ListSbirTopicsOptions { + 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, + ); + push_opt(&mut q, "activity", self.activity.as_deref()); + push_opt(&mut q, "agency", self.agency.as_deref()); + push_opt(&mut q, "topic_number", self.topic_number.as_deref()); + push_opt( + &mut q, + "solicitation_number", + self.solicitation_number.as_deref(), + ); + push_opt(&mut q, "year", self.year.as_deref()); + push_opt(&mut q, "doc_source", self.doc_source.as_deref()); + push_opt(&mut q, "close_date_after", self.close_date_after.as_deref()); + push_opt( + &mut q, + "close_date_before", + self.close_date_before.as_deref(), + ); + push_opt(&mut q, "open_date_after", self.open_date_after.as_deref()); + push_opt(&mut q, "open_date_before", self.open_date_before.as_deref()); + push_opt( + &mut q, + "release_date_after", + self.release_date_after.as_deref(), + ); + push_opt( + &mut q, + "release_date_before", + self.release_date_before.as_deref(), + ); + push_opt(&mut q, "search", self.search.as_deref()); + 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 + } +} + +/// Options for [`Client::list_sbir_solicitations`] and [`Client::iterate_sbir_solicitations`]. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct ListSbirSolicitationsOptions { + /// 1-based page number. + #[builder(into)] + pub page: Option, + /// Page size (server caps at 100). + #[builder(into)] + pub limit: Option, + /// Keyset cursor. + #[builder(into)] + pub cursor: Option, + /// Comma-separated field selector. Use [`SHAPE_SBIR_SOLICITATIONS_MINIMAL`](crate::SHAPE_SBIR_SOLICITATIONS_MINIMAL) or roll your own. + #[builder(into)] + pub shape: Option, + /// Collapse nested objects into dot-separated keys. + #[builder(default)] + pub flat: bool, + /// When [`flat`](Self::flat) is also true, flatten list-valued fields. + #[builder(default)] + pub flat_lists: bool, + + /// Whether the cycle is open today: `open` or `closed`. + #[builder(into)] + pub activity: Option, + /// Program (`SBIR` or `STTR`). + #[builder(into)] + pub program: Option, + /// Solicitation number. + #[builder(into)] + pub solicitation_number: Option, + /// Cycle name. + #[builder(into)] + pub cycle_name: Option, + /// Raw feed status. + #[builder(into)] + pub solicitation_status: Option, + /// Out-of-cycle solicitation flag. `Some(false)` reaches the server as a filter value. + pub out_of_cycle: Option, + /// Solicitation year. + #[builder(into)] + pub year: Option, + /// Cycle start date on or after (`YYYY-MM-DD`). + #[builder(into)] + pub start_date_after: Option, + /// Cycle start date on or before (`YYYY-MM-DD`). + #[builder(into)] + pub start_date_before: Option, + /// Cycle end date on or after (`YYYY-MM-DD`). + #[builder(into)] + pub end_date_after: Option, + /// Cycle end date on or before (`YYYY-MM-DD`). + #[builder(into)] + pub end_date_before: Option, + /// Case-insensitive substring match across title and solicitation number. + #[builder(into)] + pub search: Option, + /// One of `activity`, `start_date`, `end_date`, `year`, `modified`, each optionally `-` prefixed. + #[builder(into)] + pub ordering: Option, + + /// Escape hatch for filter keys not yet first-classed on this struct. + #[builder(default)] + pub extra: BTreeMap, +} + +impl ListSbirSolicitationsOptions { + 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, + ); + push_opt(&mut q, "activity", self.activity.as_deref()); + push_opt(&mut q, "program", self.program.as_deref()); + push_opt( + &mut q, + "solicitation_number", + self.solicitation_number.as_deref(), + ); + push_opt(&mut q, "cycle_name", self.cycle_name.as_deref()); + push_opt( + &mut q, + "solicitation_status", + self.solicitation_status.as_deref(), + ); + push_opt_bool(&mut q, "out_of_cycle", self.out_of_cycle); + push_opt(&mut q, "year", self.year.as_deref()); + push_opt(&mut q, "start_date_after", self.start_date_after.as_deref()); + push_opt( + &mut q, + "start_date_before", + self.start_date_before.as_deref(), + ); + push_opt(&mut q, "end_date_after", self.end_date_after.as_deref()); + push_opt(&mut q, "end_date_before", self.end_date_before.as_deref()); + push_opt(&mut q, "search", self.search.as_deref()); + 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 + } +} + +/// Options for [`Client::get_sbir_topic`], [`Client::get_sbir_solicitation`]. +#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] +#[non_exhaustive] +pub struct GetSbirOptions { + /// Shape selector. When empty, the server returns its default detail shape. + #[builder(into)] + pub shape: Option, + /// Flatten nested objects into dot-separated keys. + #[builder(default)] + pub flat: bool, + /// When `flat=true`, also flatten list-valued nested fields. + #[builder(default)] + pub flat_lists: bool, +} + +impl GetSbirOptions { + pub(crate) fn to_query(&self) -> Vec<(String, String)> { + let mut q = Vec::new(); + push_opt(&mut q, "shape", self.shape.as_deref()); + if self.flat { + q.push(("flat".into(), "true".into())); + } + if self.flat_lists { + q.push(("flat_lists".into(), "true".into())); + } + q + } +} + +impl Client { + /// `GET /api/sbir/topics/` — one page of SBIR/STTR topic records. + pub async fn list_sbir_topics(&self, opts: ListSbirTopicsOptions) -> Result> { + let q = opts.to_query(); + let bytes = self.get_bytes("/api/sbir/topics/", &q).await?; + Page::decode(&bytes) + } + + /// `GET /api/sbir/topics/{topic_id}/` — a single SBIR/STTR topic. + pub async fn get_sbir_topic( + &self, + topic_id: &str, + opts: Option, + ) -> Result { + if topic_id.is_empty() { + return Err(Error::Validation { + message: "get_sbir_topic: topic_id is required".into(), + response: None, + }); + } + let q = opts.unwrap_or_default().to_query(); + let path = format!("/api/sbir/topics/{}/", urlencoding(topic_id)); + self.get_json::(&path, &q).await + } + + /// Stream every SBIR/STTR topic matching `opts`. + pub fn iterate_sbir_topics(&self, opts: ListSbirTopicsOptions) -> PageStream { + let opts = Arc::new(opts); + let fetch: FetchFn = Box::new(move |client, page, cursor| { + let mut next = (*opts).clone(); + next.page = page; + next.cursor = cursor; + Box::pin(async move { client.list_sbir_topics(next).await }) + }); + PageStream::new(self.clone(), fetch) + } + + /// `GET /api/sbir/solicitations/` — one page of DoD DSIP solicitation cycle records. + pub async fn list_sbir_solicitations( + &self, + opts: ListSbirSolicitationsOptions, + ) -> Result> { + let q = opts.to_query(); + let bytes = self.get_bytes("/api/sbir/solicitations/", &q).await?; + Page::decode(&bytes) + } + + /// `GET /api/sbir/solicitations/{solicitation_id}/` — a single DoD DSIP solicitation cycle. + pub async fn get_sbir_solicitation( + &self, + solicitation_id: &str, + opts: Option, + ) -> Result { + if solicitation_id.is_empty() { + return Err(Error::Validation { + message: "get_sbir_solicitation: solicitation_id is required".into(), + response: None, + }); + } + let q = opts.unwrap_or_default().to_query(); + let path = format!("/api/sbir/solicitations/{}/", urlencoding(solicitation_id)); + self.get_json::(&path, &q).await + } + + /// Stream every DoD DSIP solicitation cycle matching `opts`. + pub fn iterate_sbir_solicitations( + &self, + opts: ListSbirSolicitationsOptions, + ) -> PageStream { + let opts = Arc::new(opts); + let fetch: FetchFn = Box::new(move |client, page, cursor| { + let mut next = (*opts).clone(); + next.page = page; + next.cursor = cursor; + Box::pin(async move { client.list_sbir_solicitations(next).await }) + }); + PageStream::new(self.clone(), fetch) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn get_q(q: &[(String, String)], k: &str) -> Option { + q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone()) + } + + #[test] + fn list_sbir_topics_all_filters_emit() { + let opts = ListSbirTopicsOptions::builder() + .activity("v-activity") + .agency("v-agency") + .topic_number("v-topic_number") + .solicitation_number("v-solicitation_number") + .year("v-year") + .doc_source("v-doc_source") + .close_date_after("v-close_date_after") + .close_date_before("v-close_date_before") + .open_date_after("v-open_date_after") + .open_date_before("v-open_date_before") + .release_date_after("v-release_date_after") + .release_date_before("v-release_date_before") + .search("v-search") + .ordering("v-ordering") + .build(); + let q = opts.to_query(); + assert_eq!(get_q(&q, "activity").as_deref(), Some("v-activity")); + assert_eq!(get_q(&q, "agency").as_deref(), Some("v-agency")); + assert_eq!(get_q(&q, "topic_number").as_deref(), Some("v-topic_number")); + assert_eq!( + get_q(&q, "solicitation_number").as_deref(), + Some("v-solicitation_number") + ); + assert_eq!(get_q(&q, "year").as_deref(), Some("v-year")); + assert_eq!(get_q(&q, "doc_source").as_deref(), Some("v-doc_source")); + assert_eq!( + get_q(&q, "close_date_after").as_deref(), + Some("v-close_date_after") + ); + assert_eq!( + get_q(&q, "close_date_before").as_deref(), + Some("v-close_date_before") + ); + assert_eq!( + get_q(&q, "open_date_after").as_deref(), + Some("v-open_date_after") + ); + assert_eq!( + get_q(&q, "open_date_before").as_deref(), + Some("v-open_date_before") + ); + assert_eq!( + get_q(&q, "release_date_after").as_deref(), + Some("v-release_date_after") + ); + assert_eq!( + get_q(&q, "release_date_before").as_deref(), + Some("v-release_date_before") + ); + assert_eq!(get_q(&q, "search").as_deref(), Some("v-search")); + assert_eq!(get_q(&q, "ordering").as_deref(), Some("v-ordering")); + assert_eq!(q.len(), 14); + } + + #[test] + fn list_sbir_topics_zero_value_omitted() { + assert!(ListSbirTopicsOptions::builder() + .build() + .to_query() + .is_empty()); + } + + #[test] + fn list_sbir_topics_cursor_wins_over_page_and_shape_emits() { + let opts = ListSbirTopicsOptions::builder() + .page(3u32) + .cursor("c0".to_string()) + .shape(crate::SHAPE_SBIR_TOPICS_MINIMAL) + .build(); + let q = opts.to_query(); + assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0")); + assert_eq!(get_q(&q, "page"), None); + assert_eq!( + get_q(&q, "shape").as_deref(), + Some(crate::SHAPE_SBIR_TOPICS_MINIMAL) + ); + } + + #[test] + fn list_sbir_topics_extra_emits() { + let mut extra = BTreeMap::new(); + extra.insert("custom_x".to_string(), "xv".to_string()); + let q = ListSbirTopicsOptions::builder() + .extra(extra) + .build() + .to_query(); + assert!(q.contains(&("custom_x".into(), "xv".into()))); + } + + #[tokio::test] + async fn get_sbir_topic_validates_empty_topic_id() { + let client = Client::builder().api_key("x").build().expect("client"); + let err = client.get_sbir_topic("", None).await.unwrap_err(); + match err { + Error::Validation { message, .. } => { + assert!(message.contains("topic_id is required")); + } + other => panic!("expected Validation, got {other:?}"), + } + } + + #[test] + fn list_sbir_solicitations_all_filters_emit() { + let opts = ListSbirSolicitationsOptions::builder() + .activity("v-activity") + .program("v-program") + .solicitation_number("v-solicitation_number") + .cycle_name("v-cycle_name") + .solicitation_status("v-solicitation_status") + .out_of_cycle(true) + .year("v-year") + .start_date_after("v-start_date_after") + .start_date_before("v-start_date_before") + .end_date_after("v-end_date_after") + .end_date_before("v-end_date_before") + .search("v-search") + .ordering("v-ordering") + .build(); + let q = opts.to_query(); + assert_eq!(get_q(&q, "activity").as_deref(), Some("v-activity")); + assert_eq!(get_q(&q, "program").as_deref(), Some("v-program")); + assert_eq!( + get_q(&q, "solicitation_number").as_deref(), + Some("v-solicitation_number") + ); + assert_eq!(get_q(&q, "cycle_name").as_deref(), Some("v-cycle_name")); + assert_eq!( + get_q(&q, "solicitation_status").as_deref(), + Some("v-solicitation_status") + ); + assert_eq!(get_q(&q, "out_of_cycle").as_deref(), Some("true")); + assert_eq!(get_q(&q, "year").as_deref(), Some("v-year")); + assert_eq!( + get_q(&q, "start_date_after").as_deref(), + Some("v-start_date_after") + ); + assert_eq!( + get_q(&q, "start_date_before").as_deref(), + Some("v-start_date_before") + ); + assert_eq!( + get_q(&q, "end_date_after").as_deref(), + Some("v-end_date_after") + ); + assert_eq!( + get_q(&q, "end_date_before").as_deref(), + Some("v-end_date_before") + ); + assert_eq!(get_q(&q, "search").as_deref(), Some("v-search")); + assert_eq!(get_q(&q, "ordering").as_deref(), Some("v-ordering")); + assert_eq!(q.len(), 13); + } + + #[test] + fn list_sbir_solicitations_out_of_cycle_false_is_a_filter_not_an_absence() { + let opts = ListSbirSolicitationsOptions::builder() + .out_of_cycle(false) + .build(); + assert_eq!( + get_q(&opts.to_query(), "out_of_cycle").as_deref(), + Some("false") + ); + } + + #[test] + fn list_sbir_solicitations_zero_value_omitted() { + assert!(ListSbirSolicitationsOptions::builder() + .build() + .to_query() + .is_empty()); + } + + #[test] + fn list_sbir_solicitations_cursor_wins_over_page_and_shape_emits() { + let opts = ListSbirSolicitationsOptions::builder() + .page(3u32) + .cursor("c0".to_string()) + .shape(crate::SHAPE_SBIR_SOLICITATIONS_MINIMAL) + .build(); + let q = opts.to_query(); + assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0")); + assert_eq!(get_q(&q, "page"), None); + assert_eq!( + get_q(&q, "shape").as_deref(), + Some(crate::SHAPE_SBIR_SOLICITATIONS_MINIMAL) + ); + } + + #[test] + fn list_sbir_solicitations_extra_emits() { + let mut extra = BTreeMap::new(); + extra.insert("custom_x".to_string(), "xv".to_string()); + let q = ListSbirSolicitationsOptions::builder() + .extra(extra) + .build() + .to_query(); + assert!(q.contains(&("custom_x".into(), "xv".into()))); + } + + #[tokio::test] + async fn get_sbir_solicitation_validates_empty_solicitation_id() { + let client = Client::builder().api_key("x").build().expect("client"); + let err = client.get_sbir_solicitation("", None).await.unwrap_err(); + match err { + Error::Validation { message, .. } => { + assert!(message.contains("solicitation_id is required")); + } + other => panic!("expected Validation, got {other:?}"), + } + } + + #[test] + fn get_options_emits() { + let q = GetSbirOptions::builder() + .shape("a,b") + .flat(true) + .flat_lists(true) + .build() + .to_query(); + assert_eq!(get_q(&q, "shape").as_deref(), Some("a,b")); + assert_eq!(get_q(&q, "flat").as_deref(), Some("true")); + assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true")); + } +} diff --git a/crates/tango/src/resources/sled.rs b/crates/tango/src/resources/sled.rs index e4367fc..c49aade 100644 --- a/crates/tango/src/resources/sled.rs +++ b/crates/tango/src/resources/sled.rs @@ -179,6 +179,9 @@ pub struct ListSledOpportunitiesOptions { #[builder(into)] pub ordering: Option, + /// When `Some(true)`, add `description` and `contact` to each list row. `description` is detail-only by default because its longest values run past 120,000 characters. + pub verbose: Option, + /// Escape hatch for filter keys not yet first-classed on this struct. #[builder(default)] pub extra: BTreeMap, @@ -247,6 +250,7 @@ impl ListSledOpportunitiesOptions { push_opt(&mut q, "native_id", self.native_id.as_deref()); push_opt(&mut q, "external_id", self.external_id.as_deref()); push_opt(&mut q, "search", self.search.as_deref()); + push_opt_bool(&mut q, "verbose", self.verbose); push_opt(&mut q, "ordering", self.ordering.as_deref()); for (k, v) in &self.extra { if !v.is_empty() { @@ -967,4 +971,15 @@ mod tests { other => panic!("expected Validation, got {other:?}"), } } + + #[test] + fn list_sled_opportunities_verbose_emits() { + let q = ListSledOpportunitiesOptions::builder() + .verbose(true) + .build() + .to_query(); + assert_eq!(get_q(&q, "verbose").as_deref(), Some("true")); + let q = ListSledOpportunitiesOptions::builder().build().to_query(); + assert_eq!(get_q(&q, "verbose"), None); + } } diff --git a/crates/tango/src/shapes.rs b/crates/tango/src/shapes.rs index d9a0e67..8444fc6 100644 --- a/crates/tango/src/shapes.rs +++ b/crates/tango/src/shapes.rs @@ -14,11 +14,17 @@ pub const DEFAULT_BASE_URL: &str = "https://tango.makegov.com"; pub const SHAPE_CONTRACTS_MINIMAL: &str = "key,piid,award_date,recipient(display_name),description,total_contract_value"; -/// Default shape for -/// [`Client::list_budget_accounts`](crate::Client::list_budget_accounts). +/// Default shape for [`Client::list_budget_accounts`](crate::Client::list_budget_accounts) and [`Client::get_budget_account`](crate::Client::get_budget_account). +/// +/// Mirrors the API's own default budget-account shape. pub const SHAPE_BUDGET_ACCOUNTS_MINIMAL: &str = concat!( - "federal_account_symbol,fiscal_year,agency_name,enacted_ba,", - "obligated_total,contract_obligated,contract_share_of_obligated_capped" + "id,federal_account_symbol,fiscal_year,agency_code,agency_name,bureau_name,", + "account_title,bea_category,on_off_budget,subfunction_code,", + "requested_ba,enacted_ba,apportioned,obligated_total,outlayed_total,", + "unobligated_balance,contract_obligated,contract_share_of_obligated_capped,", + "assistance_obligated,obligated_to_apportioned_pct_capped,", + "obligated_to_enacted_pct_capped,outlayed_to_obligated_pct_capped,", + "ba_growth_next_year_pct", ); /// Default shape for [`Client::list_entities`](crate::Client::list_entities). @@ -58,6 +64,30 @@ pub const SHAPE_CONTRACT_APPEALS_MINIMAL: &str = concat!( "decision_type,url,listed", ); +/// Suggested list shape for [`Client::list_dibbs_rfqs`](crate::Client::list_dibbs_rfqs). +pub const SHAPE_DIBBS_RFQS_MINIMAL: &str = concat!( + "uuid,solicitation,nsn,part_number,nomenclature,quantity,", + "issue_date,return_by_date,is_open", +); + +/// Suggested list shape for [`Client::list_dibbs_rfps`](crate::Client::list_dibbs_rfps). +pub const SHAPE_DIBBS_RFPS_MINIMAL: &str = + "uuid,solicitation,nsn,part_number,nomenclature,issued_date,closes_date,is_open"; + +/// Suggested list shape for [`Client::list_dibbs_awards`](crate::Client::list_dibbs_awards). +/// +/// `total_contract_price` is the order total repeated on every line item, so never sum it across rows. +pub const SHAPE_DIBBS_AWARDS_MINIMAL: &str = concat!( + "uuid,award_number,solicitation,nsn,part_number,nomenclature,", + "awardee_cage,award_date,total_contract_price", +); + +/// Suggested list shape for [`Client::list_exclusions`](crate::Client::list_exclusions). +pub const SHAPE_EXCLUSIONS_MINIMAL: &str = concat!( + "exclusion_key,display_name,entity_name,uei,classification_type,exclusion_type,", + "excluding_agency_name,activate_date,termination_date,is_currently_excluded", +); + /// Default shape for [`Client::list_grants`](crate::Client::list_grants). pub const SHAPE_GRANTS_MINIMAL: &str = "grant_id,opportunity_number,title,status(*),agency_code"; @@ -215,3 +245,15 @@ pub const SHAPE_SLED_FORECASTS_COMPREHENSIVE: &str = concat!( "has_documents,first_seen_at,last_seen_at,", "organization(*),contact(*),estimated_value(*)", ); + +/// Suggested list shape for [`Client::list_sbir_topics`](crate::Client::list_sbir_topics). +pub const SHAPE_SBIR_TOPICS_MINIMAL: &str = concat!( + "topic_id,topic_number,title,agency,activity,year,", + "solicitation_number,open_date,close_date,listed_open", +); + +/// Suggested list shape for [`Client::list_sbir_solicitations`](crate::Client::list_sbir_solicitations). +pub const SHAPE_SBIR_SOLICITATIONS_MINIMAL: &str = concat!( + "solicitation_id,solicitation_number,title,program,activity,", + "cycle_name,solicitation_status,year,start_date,end_date", +); diff --git a/crates/tango/tests/routes.rs b/crates/tango/tests/routes.rs new file mode 100644 index 0000000..6ca1033 --- /dev/null +++ b/crates/tango/tests/routes.rs @@ -0,0 +1,368 @@ +//! Wire tests for resource routes: each method hits the path and query the API documents, and decodes the response shape the API actually serves. + +use httpmock::prelude::*; +use serde_json::json; +use std::time::Duration; +use tango::{ + BudgetAccountQuartersOptions, BudgetAccountRecipientsOptions, Client, EntityBudgetFlowsOptions, + GetDibbsOptions, GetExclusionOptions, GetSbirOptions, ListDibbsAwardsOptions, + ListDibbsRfpsOptions, ListDibbsRfqsOptions, ListExclusionsOptions, + ListSbirSolicitationsOptions, ListSbirTopicsOptions, +}; + +fn make_client(server: &MockServer) -> Client { + Client::builder() + .api_key("test-key") + .base_url(server.base_url()) + .retries(0u32) + .timeout(Duration::from_secs(5)) + .build() + .expect("client") +} + +fn page(results: serde_json::Value) -> serde_json::Value { + json!({"count": 1, "next": null, "previous": null, "results": results}) +} + +#[tokio::test] +async fn get_department_decodes_integer_code() { + let server = MockServer::start_async().await; + let m = server + .mock_async(|when, then| { + when.method(GET).path("/api/departments/97/"); + then.status(200).json_body( + json!({"abbreviation": "DOD", "code": 97, "name": "Department of Defense"}), + ); + }) + .await; + let rec = make_client(&server) + .get_department("97") + .await + .expect("decode"); + m.assert_async().await; + assert_eq!( + rec.get("code").and_then(serde_json::Value::as_i64), + Some(97) + ); +} + +#[tokio::test] +async fn get_agency_decodes_string_code_with_integer_department_code() { + let server = MockServer::start_async().await; + let _m = server + .mock_async(|when, then| { + when.method(GET).path("/api/agencies/9700/"); + then.status(200).json_body(json!({ + "abbreviation": "DOD", + "code": "9700", + "department": {"code": 97, "name": "Department of Defense"}, + "name": "Department of Defense" + })); + }) + .await; + let agency = make_client(&server) + .get_agency("9700", None) + .await + .expect("decode"); + assert_eq!(agency.code.as_deref(), Some("9700")); + assert_eq!( + agency + .department + .as_ref() + .and_then(|d| d.get("code")) + .and_then(serde_json::Value::as_i64), + Some(97) + ); +} + +#[tokio::test] +async fn dibbs_routes() { + let server = MockServer::start_async().await; + let quote_list = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/dibbs/rfqs/") + .query_param("open", "false") + .query_param("nsn", "5340-01-123-4567"); + then.status(200).json_body(page(json!([{"uuid": "r1"}]))); + }) + .await; + let proposal_list = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/dibbs/rfps/") + .query_param("buyer_code", "PB"); + then.status(200).json_body(page(json!([{"uuid": "p1"}]))); + }) + .await; + let awards = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/dibbs/awards/") + .query_param("awardee_cage", "1ABC5"); + then.status(200).json_body(page(json!([{"uuid": "a1"}]))); + }) + .await; + let get_award = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/dibbs/awards/a1/") + .query_param("shape", "uuid"); + then.status(200).json_body(json!({"uuid": "a1"})); + }) + .await; + let c = make_client(&server); + let p = c + .list_dibbs_rfqs( + ListDibbsRfqsOptions::builder() + .open(false) + .nsn("5340-01-123-4567") + .build(), + ) + .await + .expect("rfqs"); + assert_eq!(p.results.len(), 1); + c.list_dibbs_rfps(ListDibbsRfpsOptions::builder().buyer_code("PB").build()) + .await + .expect("rfps"); + c.list_dibbs_awards( + ListDibbsAwardsOptions::builder() + .awardee_cage("1ABC5") + .build(), + ) + .await + .expect("awards"); + let rec = c + .get_dibbs_award("a1", Some(GetDibbsOptions::builder().shape("uuid").build())) + .await + .expect("award"); + assert_eq!( + rec.get("uuid").and_then(serde_json::Value::as_str), + Some("a1") + ); + quote_list.assert_async().await; + proposal_list.assert_async().await; + awards.assert_async().await; + get_award.assert_async().await; +} + +#[tokio::test] +async fn exclusion_routes() { + let server = MockServer::start_async().await; + let list = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/exclusions/") + .query_param("active", "true") + .query_param("classification_type", "Firm"); + then.status(200) + .json_body(page(json!([{"exclusion_key": "abc"}]))); + }) + .await; + let get = server + .mock_async(|when, then| { + when.method(GET).path("/api/exclusions/abc/"); + then.status(200).json_body(json!({"exclusion_key": "abc"})); + }) + .await; + let c = make_client(&server); + c.list_exclusions( + ListExclusionsOptions::builder() + .active(true) + .classification_type("Firm") + .build(), + ) + .await + .expect("list"); + c.get_exclusion("abc", None::) + .await + .expect("get"); + list.assert_async().await; + get.assert_async().await; +} + +#[tokio::test] +async fn sbir_routes() { + let server = MockServer::start_async().await; + let topics = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/sbir/topics/") + .query_param("activity", "open"); + then.status(200) + .json_body(page(json!([{"topic_id": "t1"}]))); + }) + .await; + let topic = server + .mock_async(|when, then| { + when.method(GET).path("/api/sbir/topics/t1/"); + then.status(200).json_body(json!({"topic_id": "t1"})); + }) + .await; + let sols = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/sbir/solicitations/") + .query_param("out_of_cycle", "false"); + then.status(200) + .json_body(page(json!([{"solicitation_id": "s1"}]))); + }) + .await; + let sol = server + .mock_async(|when, then| { + when.method(GET).path("/api/sbir/solicitations/s1/"); + then.status(200).json_body(json!({"solicitation_id": "s1"})); + }) + .await; + let c = make_client(&server); + c.list_sbir_topics(ListSbirTopicsOptions::builder().activity("open").build()) + .await + .expect("topics"); + c.get_sbir_topic("t1", None::) + .await + .expect("topic"); + c.list_sbir_solicitations( + ListSbirSolicitationsOptions::builder() + .out_of_cycle(false) + .build(), + ) + .await + .expect("solicitations"); + c.get_sbir_solicitation("s1", None).await.expect("sol"); + topics.assert_async().await; + topic.assert_async().await; + sols.assert_async().await; + sol.assert_async().await; +} + +#[tokio::test] +async fn budget_sub_routes_send_their_own_filters() { + let server = MockServer::start_async().await; + let quarters = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/budget/accounts/123/quarters/") + .query_param("tas", "097-2021/2022-0100"); + then.status(200).json_body(json!({ + "count": 1, "next": null, "previous": null, + "results": [{"tas": "097-2021/2022-0100", "quarter": 1}], + "federal_account_symbol": "097-0100", + "fiscal_year": 2022 + })); + }) + .await; + let recipients = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/budget/accounts/123/recipients/") + .query_param("funding_organization_id", "org-uuid"); + then.status(200) + .json_body(page(json!([{"recipient_id": "UEI1"}]))); + }) + .await; + let flows = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/entities/UEI1/budget-flows/") + .query_param("fiscal_year", "2024"); + then.status(200) + .json_body(page(json!([{"federal_account_symbol": "097-0100"}]))); + }) + .await; + let c = make_client(&server); + let q = c + .get_budget_account_quarters( + "123", + Some( + BudgetAccountQuartersOptions::builder() + .tas("097-2021/2022-0100") + .build(), + ), + ) + .await + .expect("quarters"); + assert_eq!(q.results.len(), 1); + c.get_budget_account_recipients( + "123", + Some( + BudgetAccountRecipientsOptions::builder() + .funding_organization_id("org-uuid") + .build(), + ), + ) + .await + .expect("recipients"); + c.get_entity_budget_flows( + "UEI1", + Some( + EntityBudgetFlowsOptions::builder() + .fiscal_year(2024u32) + .build(), + ), + ) + .await + .expect("flows"); + quarters.assert_async().await; + recipients.assert_async().await; + flows.assert_async().await; +} + +#[tokio::test] +async fn singleton_detail_routes() { + let server = MockServer::start_async().await; + let paths = [ + "/api/contracts/K1/", + "/api/opportunities/O1/", + "/api/notices/N1/", + "/api/forecasts/42/", + "/api/grants/G1/", + "/api/subawards/S1/", + "/api/budget/accounts/123/", + ]; + let mut mocks = Vec::new(); + for p in paths { + mocks.push( + server + .mock_async(|when, then| { + when.method(GET).path(p); + then.status(200).json_body(json!({"ok": true})); + }) + .await, + ); + } + let c = make_client(&server); + c.get_contract("K1", None).await.expect("contract"); + c.get_opportunity("O1", None).await.expect("opportunity"); + c.get_notice("N1", None).await.expect("notice"); + c.get_forecast("42", None).await.expect("forecast"); + c.get_grant("G1", None).await.expect("grant"); + c.get_subaward("S1", None).await.expect("subaward"); + c.get_budget_account("123", None).await.expect("budget"); + for m in &mocks { + m.assert_async().await; + } +} + +#[tokio::test] +async fn contract_sub_routes() { + let server = MockServer::start_async().await; + let subs = server + .mock_async(|when, then| { + when.method(GET).path("/api/contracts/K1/subawards/"); + then.status(200).json_body(page(json!([]))); + }) + .await; + let txns = server + .mock_async(|when, then| { + when.method(GET).path("/api/contracts/K1/transactions/"); + then.status(200).json_body(page(json!([]))); + }) + .await; + let c = make_client(&server); + c.list_contract_subawards("K1", None).await.expect("subs"); + c.list_contract_transactions("K1", None) + .await + .expect("txns"); + subs.assert_async().await; + txns.assert_async().await; +} diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index edc97e0..c44d6c2 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -35,7 +35,7 @@ Options: `ListAgenciesOptions`, `GetAgencyOptions`, `AgencyContractsOptions` (al | `list_contract_subawards(key, opts)` | `GET /api/contracts/{key}/subawards/` | `Page` | | `list_contract_transactions(key, opts)` | `GET /api/contracts/{key}/transactions/` | `Page` | -Options: `ListContractsOptions` (list), `ListOptions` (`get_contract`), `EntitySubresourceOptions` (sub-routes). SDK-friendly filter aliases (`naics_code`, `psc_code`, `recipient_name`, `recipient_uei`, `set_aside_type`, `keyword`) map onto canonical API names. When both are set, the SDK alias wins (mirrors Node/Python). `sort`+`order` combine into `ordering` with `-` prefix for descending. +Options: `ListContractsOptions` (list), `ListOptions` (`get_contract`), `EntitySubresourceOptions` (sub-routes; `/transactions/` honours only pagination and `ordering`). SDK-friendly filter aliases (`naics_code`, `psc_code`, `recipient_name`, `recipient_uei`, `set_aside_type`, `keyword`) map onto canonical API names. When both are set, the SDK alias wins (mirrors Node/Python). `sort`+`order` combine into `ordering` with `-` prefix for descending. ### IDVs (`idvs.rs`, `idv_subresources.rs`) @@ -44,7 +44,7 @@ Options: `ListContractsOptions` (list), `ListOptions` (`get_contract`), `EntityS | `list_idvs(opts)` / `iterate_idvs(opts)` | `GET /api/idvs/` | `Page` / `PageStream` | | `get_idv(key, opts)` | `GET /api/idvs/{key}/` | `Record` | | `list_idv_awards(key, opts)` / `iterate_*` | `GET /api/idvs/{key}/awards/` | `Page` / `PageStream` | -| `list_idv_child_idvs(key, opts)` / `iterate_*` | `GET /api/idvs/{key}/child-idvs/` | `Page` / `PageStream` | +| `list_idv_child_idvs(key, opts)` / `iterate_*` | `GET /api/idvs/{key}/idvs/` | `Page` / `PageStream` | | `list_idv_transactions(key, opts)` / `iterate_*` | `GET /api/idvs/{key}/transactions/` | `Page` / `PageStream` | | `list_idv_lcats(key, opts)` / `iterate_*` | `GET /api/idvs/{key}/lcats/` | `Page` / `PageStream` | @@ -65,7 +65,7 @@ Options: `ListIDVsOptions`, `GetIDVOptions`, `IdvSubresourceOptions` (shared acr | `get_entity_budget_flows(uei, opts)` | `GET /api/entities/{uei}/budget-flows/` | `Page` | | `get_entity_metrics(uei, months, period_grouping)` | `GET /api/entities/{uei}/metrics/{months}/{period_grouping}/` | `Record` | -Options: `ListEntitiesOptions`, `GetEntityOptions`, `EntitySubresourceOptions` (shared across sub-resource list endpoints). `ListEntitiesOptions` exposes both `cage` and `cage_code` as distinct typed filters; the server rejects setting both. +Options: `ListEntitiesOptions`, `GetEntityOptions`, `EntitySubresourceOptions` (shared across sub-resource list endpoints), `EntityBudgetFlowsOptions` (`get_entity_budget_flows`: pagination plus `fiscal_year`). `ListEntitiesOptions` exposes `cage` as an alias for `cage_code`; the server rejects a request that sets both. Budget flows are contract flows only: grant and assistance flows are not in that index. ### Vehicles (`vehicles.rs`, `vehicle_subresources.rs`) @@ -90,9 +90,11 @@ Options: `ListVehiclesOptions`, `GetVehicleOptions`, `ListVehicleAwardeesOptions | `get_forecast(id, opts)` | `GET /api/forecasts/{id}/` | `Record` | | `list_grants(opts)` / `iterate_*` | `GET /api/grants/` | `Page` / `PageStream` | | `get_grant(grant_id, opts)` | `GET /api/grants/{grant_id}/` | `Record` | -| `search_opportunity_attachments(opts)` | `GET /api/opportunities/attachment-search/` | `Page` | +| `search_opportunity_attachments(opts)` *(deprecated)* | `GET /api/opportunities/attachment-search/` | `Record` | -Options: `ListOpportunitiesOptions`, `ListNoticesOptions`, `ListForecastsOptions`, `ListGrantsOptions`, `SearchOpportunityAttachmentsOptions`. The singleton `get_*` methods take `Option`. The attachment-search method validates `q` non-empty client-side. `ListGrantsOptions` exposes a typed `grant_id` filter. +Options: `ListOpportunitiesOptions`, `ListNoticesOptions`, `ListForecastsOptions`, `ListGrantsOptions`, `SearchOpportunityAttachmentsOptions`. The singleton `get_*` methods take `Option`. `ListGrantsOptions` exposes a typed `grant_id` filter; `ListNoticesOptions` adds `notice_id`, `department` and `office`; `ListOpportunitiesOptions` adds `opportunity_id`; `ListForecastsOptions` adds `id`. + +**`search_opportunity_attachments` is deprecated.** The API retired `/api/opportunities/attachment-search/`: it returns 404 for every query and keeps the route only so a missing `q` still gets its 400. `list_opportunities` with `search` matches attachment text and returns a `snippet` for the hit. ### OTAs / OTIDVs (`otas.rs`) @@ -104,7 +106,7 @@ Options: `ListOpportunitiesOptions`, `ListNoticesOptions`, `ListForecastsOptions | `get_otidv(key, opts)` | `GET /api/otidvs/{key}/` | `Record` | | `list_otidv_awards(key, opts)` / `iterate_*` | `GET /api/otidvs/{key}/awards/` | `Page` / `PageStream` | -Options: `ListOTAsOptions`, `GetOTAOptions`, `ListOTIDVsOptions`, `GetOTIDVOptions`, `ListOTIDVAwardsOptions`. +Options: `ListOTAsOptions`, `GetOTAOptions`, `ListOTIDVsOptions`, `GetOTIDVOptions`, `ListOTIDVAwardsOptions`. Like contracts and IDVs, each list options struct carries a `key` filter that accepts `|`-separated award keys. ### Subawards (`subawards.rs`) @@ -124,7 +126,15 @@ Options: `ListSubawardsOptions` (list), `ListOptions` (`get_subaward`). **Note** | `get_budget_account_quarters(id, opts)` | `GET /api/budget/accounts/{id}/quarters/` | `Page` | | `get_budget_account_recipients(id, opts)` | `GET /api/budget/accounts/{id}/recipients/` | `Page` | -Options: `ListBudgetAccountsOptions` (list), `ListOptions` (`get_*`). The `BudgetAccount` schema is wide (~63 fields) and shape-driven; use `SHAPE_BUDGET_ACCOUNTS_MINIMAL` for a compact default. The full `__gte` / `__lte` numeric-range filters are reachable via the `extra` map. The `recipients` envelope carries extra keys (`federal_account_symbol`, `fiscal_year`) alongside the pagination fields. +One row per federal account and fiscal year, covering the budget lifecycle (requested, enacted, apportioned, obligated, outlayed) with pre-computed ratios, trends and the contract / assistance breakdown. + +Options: `ListBudgetAccountsOptions` (list), `ListOptions` (`get_budget_account`), `BudgetAccountQuartersOptions` (pagination plus `tas`), `BudgetAccountRecipientsOptions` (pagination plus `funding_organization_id`). `SHAPE_BUDGET_ACCOUNTS_MINIMAL` mirrors the API's default shape. + +- **The list endpoint rejects an unknown filter name** with a 400 and a did-you-mean, rather than silently returning the unfiltered page. Typed fields cover the identity filters (`federal_account_symbol`, `fiscal_year` and its range, `agency_code`, `bureau_name`, `account_title`, `bea_category`, `on_off_budget`, `subfunction_code`); the numeric `__gte` / `__lte` range filters and the `__in` variants go through `extra`. +- **`{id}` is the row's numeric `id`**, not the federal account symbol. +- **`quarters` covers FY2021 onward**; an earlier account-year returns an empty page. +- **`recipients` is contract flows only.** Each row carries the resolved `funding_office` and `recipient` plus a capped `contracts` list; a row that hits the cap sets `contracts_truncated`. +- The `quarters` and `recipients` envelopes also carry `federal_account_symbol` and `fiscal_year`, which `Page` does not surface. ### GSA eLibrary (`gsa.rs`) @@ -167,6 +177,56 @@ Filters: `search`, `board` (`cbca` / `asbca`), `docket`, `appellant`, `judge`, ` **The decision body is `decision_text`, on an Enterprise plan.** Below Enterprise the key is **absent rather than null**, so `None` means "not served to this caller" and never "this decision has no text" — `text_status` and `text_char_count` describe the text at every plan. `SHAPE_CONTRACT_APPEALS_MINIMAL` deliberately does not name it, so a list page never pays for text most callers are not served. +### DIBBS (`dibbs.rs`) + +Defense Logistics Agency solicitations and awards from DIBBS. + +| Method | Endpoint | Returns | +| ------ | -------- | ------- | +| `list_dibbs_rfqs(opts)` / `iterate_dibbs_rfqs(opts)` | `GET /api/dibbs/rfqs/` | `Page` / `PageStream` | +| `get_dibbs_rfq(uuid, opts)` | `GET /api/dibbs/rfqs/{uuid}/` | `Record` | +| `list_dibbs_rfps(opts)` / `iterate_dibbs_rfps(opts)` | `GET /api/dibbs/rfps/` | `Page` / `PageStream` | +| `get_dibbs_rfp(uuid, opts)` | `GET /api/dibbs/rfps/{uuid}/` | `Record` | +| `list_dibbs_awards(opts)` / `iterate_dibbs_awards(opts)` | `GET /api/dibbs/awards/` | `Page` / `PageStream` | +| `get_dibbs_award(uuid, opts)` | `GET /api/dibbs/awards/{uuid}/` | `Record` | + +Options: `ListDibbsRfqsOptions`, `ListDibbsRfpsOptions`, `ListDibbsAwardsOptions`, `GetDibbsOptions`. Shapes: `SHAPE_DIBBS_RFQS_MINIMAL`, `SHAPE_DIBBS_RFPS_MINIMAL`, `SHAPE_DIBBS_AWARDS_MINIMAL`. + +- **Open/closed is derived at query time** from `return_by_date` (RFQs) or `closes_date` (RFPs); there is no stored flag. Filter with `open`, which is `Option` so `Some(false)` reaches the server. +- **An award row is one line item, and `total_contract_price` is the order total repeated on every line.** Never sum it across rows; deduplicate on `award_number` + `delivery_order_number` first. +- `entity` matches only awards whose CAGE code resolved to a registered entity; `awardee_cage` reaches every award. + +### Exclusions (`exclusions.rs`) + +SAM.gov exclusions: debarments, suspensions and other ineligibility records. + +| Method | Endpoint | Returns | +| ------ | -------- | ------- | +| `list_exclusions(opts)` / `iterate_exclusions(opts)` | `GET /api/exclusions/` | `Page` / `PageStream` | +| `get_exclusion(exclusion_key, opts)` | `GET /api/exclusions/{exclusion_key}/` | `Record` | + +Options: `ListExclusionsOptions`, `GetExclusionOptions`. Shape: `SHAPE_EXCLUSIONS_MINIMAL`. + +- **Whether an exclusion is in force is derived at query time**: not delisted, already activated, not yet terminated. Filter with `active` rather than trusting a stored flag. +- **`delisted` is not expiry.** It means SAM lifted or withdrew the record; a natural expiration leaves the record listed. +- Most exclusions are individuals and carry no UEI. `entity_uei` is set only when the record's UEI matches a registered entity. + +### SBIR / STTR (`sbir.rs`) + +SBIR/STTR topics and DoD DSIP solicitation cycles. + +| Method | Endpoint | Returns | +| ------ | -------- | ------- | +| `list_sbir_topics(opts)` / `iterate_sbir_topics(opts)` | `GET /api/sbir/topics/` | `Page` / `PageStream` | +| `get_sbir_topic(topic_id, opts)` | `GET /api/sbir/topics/{topic_id}/` | `Record` | +| `list_sbir_solicitations(opts)` / `iterate_sbir_solicitations(opts)` | `GET /api/sbir/solicitations/` | `Page` / `PageStream` | +| `get_sbir_solicitation(solicitation_id, opts)` | `GET /api/sbir/solicitations/{solicitation_id}/` | `Record` | + +Options: `ListSbirTopicsOptions`, `ListSbirSolicitationsOptions`, `GetSbirOptions`. Shapes: `SHAPE_SBIR_TOPICS_MINIMAL`, `SHAPE_SBIR_SOLICITATIONS_MINIMAL`. + +- **`activity` is open/closed, derived at query time** from the close or end date. A topic with no close date and no parent solicitation to inherit one from is `unknown`. +- The topic `agency` filter is a partial, case-insensitive match on the raw agency text, not organization-resolved, so variant spellings do not normalize. + ### State & Local — SLED (`sled.rs`) — **Beta** State, local and education procurement: solicitations that never appear on SAM.gov because they were never federal. Coverage is partial and grows one jurisdiction at a time. @@ -180,7 +240,7 @@ State, local and education procurement: solicitations that never appear on SAM.g | `list_sled_forecasts(opts)` / `iterate_sled_forecasts(opts)` | `GET /api/sled/forecasts/` | `Page` / `PageStream` | | `get_sled_forecast(id, opts)` | `GET /api/sled/forecasts/{forecast_id}/` | `Record` | -Options: `ListSledOpportunitiesOptions`, `ListSledOpportunityRevisionsOptions`, `ListSledForecastsOptions`, `GetSledOptions`. Shapes: `SHAPE_SLED_OPPORTUNITIES_MINIMAL` / `_COMPREHENSIVE`, `SHAPE_SLED_REVISIONS_MINIMAL`, `SHAPE_SLED_FORECASTS_MINIMAL` / `_COMPREHENSIVE`. +Options: `ListSledOpportunitiesOptions`, `ListSledOpportunityRevisionsOptions`, `ListSledForecastsOptions`, `GetSledOptions`. Shapes: `SHAPE_SLED_OPPORTUNITIES_MINIMAL` / `_COMPREHENSIVE`, `SHAPE_SLED_REVISIONS_MINIMAL`, `SHAPE_SLED_FORECASTS_MINIMAL` / `_COMPREHENSIVE`. `ListSledOpportunitiesOptions::verbose` adds `description` and `contact` to each list row; `description` is otherwise detail-only because its longest values run past 120,000 characters. **This data does not join to the federal data.** No UEI, no PIID, no agency-hierarchy key and no NAICS/PSC crosswalk; the `organization(*)` expand here is three strings, not the federal 7-key office payload. @@ -207,7 +267,7 @@ Forecasts carry **no liveness at all**: no deadline to have passed, so no `statu | `list_itdashboard(opts)` / `iterate_itdashboard(opts)` | `GET /api/itdashboard/` | `Page` / `PageStream` | | `get_itdashboard(uii, opts)` | `GET /api/itdashboard/{uii}/` | `Record` | -Options: `ListItdashboardOptions`, `GetItdashboardOptions`. Some filters are tier-gated by the server (free vs. pro vs. business+); see the rustdoc on the options struct. +Options: `ListItdashboardOptions`, `GetItdashboardOptions`. `previous_uii` finds the investment(s) that superseded a retired UII. Some filters are tier-gated by the server (free vs. pro vs. business+); see the rustdoc on the options struct. ### LCATs (`lcats.rs`) @@ -234,6 +294,8 @@ Paths: `/api/organizations/`, `/api/naics/`, `/api/psc/`, `/api/mas_sins/`, `/ap Options: `ListOrganizationsOptions`, `ListNaicsOptions`, `ListPscOptions`, `ListMasSinsOptions`, `ListAssistanceListingsOptions`, `ListBusinessTypesOptions`, `ListOfficesOptions`, `ListDepartmentsOptions`. +A department's `code` is an integer (`97` for DoD), returned as a JSON number; `get_department` takes it as a string (`"97"`). An agency's own `code` is a string (`"9700"`), and its nested `department.code` is the department's integer. + ### Metrics (`metrics.rs`) | Method | Endpoint | Returns | @@ -306,7 +368,7 @@ Options: `ListApiKeysOptions`. ## See also -- [`SHAPES.md`](SHAPES.md) — the full grammar and the 21 `SHAPE_*` preset constants. +- [`SHAPES.md`](SHAPES.md) — the full grammar and the 34 `SHAPE_*` preset constants. - [`WEBHOOKS.md`](WEBHOOKS.md) — signing, verification, middleware, CRUD. - [`CLIENT.md`](CLIENT.md) — builder options, env vars, retry semantics, error model. - [`ARCHITECTURE.md`](ARCHITECTURE.md) — design walk-through. diff --git a/docs/SHAPES.md b/docs/SHAPES.md index 3543c5e..5e2379e 100644 --- a/docs/SHAPES.md +++ b/docs/SHAPES.md @@ -2,7 +2,7 @@ Dynamic response shaping is the Tango API's signature feature: instead of always receiving every field on a resource, you tell the server exactly which fields you want, and it returns only those. Payloads stay small, responses stay fast, and the SDK doesn't have to chase schema drift. -This guide covers the shape grammar, the 21 built-in `SHAPE_*` constants, the `flat` / `flat_lists` modifiers, and the trade-offs to think about when picking a shape. +This guide covers the shape grammar, the 34 built-in `SHAPE_*` constants, the `flat` / `flat_lists` modifiers, and the trade-offs to think about when picking a shape. ## What is a shape? @@ -62,7 +62,7 @@ let page = client.list_contracts( # Ok(()) } ``` -For convenience, the SDK ships 21 preset constants. Use them when you don't need a custom selector: +For convenience, the SDK ships 34 preset constants. Use them when you don't need a custom selector: ```rust use tango::{Client, ListContractsOptions, SHAPE_CONTRACTS_MINIMAL}; @@ -76,7 +76,7 @@ let page = client.list_contracts( ## Shape preset constants -All 21 constants live in `shapes.rs` and are re-exported at the crate root. They mirror the `ShapeConfig.*` enums in `tango-node` and `tango-python` exactly — same names, same field selectors, same intent. +All 34 constants live in `shapes.rs` and are re-exported at the crate root. They mirror the `ShapeConfig.*` enums in `tango-node` and `tango-python` exactly — same names, same field selectors, same intent. | Constant | Intended use | Notes | | -------- | ------------ | ----- | @@ -101,6 +101,19 @@ All 21 constants live in `shapes.rs` and are re-exported at the crate root. They | `SHAPE_GSA_ELIBRARY_CONTRACTS_MINIMAL` | `list_gsa_elibrary_contracts` | uuid + contract_number + schedule + recipient + idv | | `SHAPE_ITDASHBOARD_INVESTMENTS_MINIMAL` | `list_itdashboard` | matches `INVESTMENT_LIST_DEFAULT_SHAPE` server-side | | `SHAPE_ITDASHBOARD_INVESTMENTS_COMPREHENSIVE` | `get_itdashboard` | matches `INVESTMENT_RETRIEVE_DEFAULT_SHAPE` server-side | +| `SHAPE_CONTRACT_APPEALS_MINIMAL` | `list_contract_appeals` | uuid, board, dockets, decision date, appellant, judge, decision type, url, listed (never `decision_text`) | +| `SHAPE_SLED_OPPORTUNITIES_MINIMAL` | `list_sled_opportunities` | solicitation identity, jurisdiction, status, dates, source URL | +| `SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE` | `get_sled_opportunity` | the above + description, contact, attachments metadata | +| `SHAPE_SLED_REVISIONS_MINIMAL` | `list_sled_opportunity_revisions` | observed_at, sequence, kind, changed_fields, source_declared | +| `SHAPE_SLED_FORECASTS_MINIMAL` | `list_sled_forecasts` | forecast identity, agency, advertisement estimate, value band | +| `SHAPE_SLED_FORECASTS_COMPREHENSIVE` | `get_sled_forecast` | the above + description, organization, contact | +| `SHAPE_BUDGET_ACCOUNTS_MINIMAL` | `list_budget_accounts` | the API's default: identity + lifecycle dollars + capped ratios | +| `SHAPE_DIBBS_RFQS_MINIMAL` | `list_dibbs_rfqs` | uuid, solicitation, NSN, part number, nomenclature, quantity, dates, is_open | +| `SHAPE_DIBBS_RFPS_MINIMAL` | `list_dibbs_rfps` | uuid, solicitation, NSN, part number, nomenclature, dates, is_open | +| `SHAPE_DIBBS_AWARDS_MINIMAL` | `list_dibbs_awards` | uuid, award number, solicitation, NSN, part, awardee CAGE, award date, order total | +| `SHAPE_EXCLUSIONS_MINIMAL` | `list_exclusions` | exclusion_key, names, UEI, classification, type, excluding agency, dates, is_currently_excluded | +| `SHAPE_SBIR_TOPICS_MINIMAL` | `list_sbir_topics` | topic identity, agency, activity, year, solicitation, open/close dates | +| `SHAPE_SBIR_SOLICITATIONS_MINIMAL` | `list_sbir_solicitations` | solicitation identity, program, activity, cycle, status, year, dates | ## `flat` and `flat_lists` modifiers From 7ba0de52e780c1b45478b9820b5a970069fed4b5 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Wed, 23 Sep 2026 10:28:37 -0500 Subject: [PATCH 3/5] ci: make the 1.80 test job actually run Rust 1.80 rust-toolchain.toml pins `stable`, and rustup prefers it over the toolchain the action installs, so the 1.80 matrix entries have been running stable. The job now sets `RUSTUP_TOOLCHAIN` from the matrix. The lockfile is re-resolved for the 1.80 floor (several transitive crates had moved to edition 2024, which Cargo 1.80 cannot parse), and `.cargo/config.toml` keeps future `cargo update` runs MSRV-aware. `quinn-proto` is pinned to 0.11.15 to clear the advisory; reqwest's HTTP/3 feature is off, so it is never compiled. Co-Authored-By: Claude Opus 5.5 (1M context) --- .cargo/config.toml | 3 + .github/workflows/test.yml | 4 + Cargo.lock | 646 ++++++++++++++++++++----------------- 3 files changed, 351 insertions(+), 302 deletions(-) create mode 100644 .cargo/config.toml diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..a4ac712 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +# Keep `cargo update` from resolving dependencies that need a newer Rust than the workspace `rust-version`, so the committed lockfile keeps building on the MSRV. +[resolver] +incompatible-rust-versions = "fallback" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 758560c..dcadb9a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,6 +22,10 @@ jobs: runs-on: ${{ matrix.os }} + # rust-toolchain.toml pins `stable`, which rustup prefers over the toolchain the action installs; this makes the matrix entry the one that actually runs. + env: + RUSTUP_TOOLCHAIN: ${{ matrix.rust }} + steps: - uses: actions/checkout@v4 diff --git a/Cargo.lock b/Cargo.lock index 7e901f2..0f49709 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -112,11 +112,11 @@ dependencies = [ [[package]] name = "async-lock" -version = "3.4.2" +version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" dependencies = [ - "event-listener 5.4.1", + "event-listener 5.4.2", "event-listener-strategy", "pin-project-lite", ] @@ -143,16 +143,16 @@ dependencies = [ "async-task", "blocking", "cfg-if", - "event-listener 5.4.1", + "event-listener 5.4.2", "futures-lite", "rustix", ] [[package]] name = "async-signal" -version = "0.2.14" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" dependencies = [ "async-io", "async-lock", @@ -202,13 +202,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -219,9 +219,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64" @@ -263,9 +263,9 @@ checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -278,9 +278,9 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel 2.5.0", "async-task", @@ -291,9 +291,9 @@ dependencies = [ [[package]] name = "bon" -version = "3.9.1" +version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" dependencies = [ "bon-macros", "rustversion", @@ -301,9 +301,9 @@ dependencies = [ [[package]] name = "bon-macros" -version = "3.9.1" +version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" dependencies = [ "darling", "ident_case", @@ -311,26 +311,26 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.2.62" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" dependencies = [ "find-msvc-tools", "shlex", @@ -338,15 +338,15 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "concurrent-queue" @@ -368,9 +368,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" @@ -390,9 +390,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ "darling_core", "darling_macro", @@ -400,26 +400,27 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" dependencies = [ + "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -462,20 +463,20 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "either" -version = "1.15.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "ena" @@ -510,11 +511,10 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -525,21 +525,21 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener 5.4.1", + "event-listener 5.4.2", "pin-project-lite", ] [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" [[package]] name = "fixedbitset" @@ -564,9 +564,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -579,9 +579,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -589,15 +589,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -606,9 +606,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -625,32 +625,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -714,15 +714,15 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.1" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -752,9 +752,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -773,24 +773,24 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", - "http 1.4.0", + "http 1.5.0", ] [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "pin-project-lite", ] @@ -859,16 +859,16 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "httparse", "itoa", "pin-project-lite", @@ -879,14 +879,15 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http 1.4.0", - "hyper 1.9.0", + "http 1.5.0", + "hyper 1.11.1", "hyper-util", "rustls", + "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -903,14 +904,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "hyper 1.9.0", + "http 1.5.0", + "http-body 1.1.0", + "hyper 1.11.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.6.5", "tokio", "tower-service", "tracing", @@ -918,23 +919,21 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" dependencies = [ "displaydoc", - "potential_utf", - "utf8_iter", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locale_core" -version = "2.2.0" +name = "icu_locid" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" dependencies = [ "displaydoc", "litemap", @@ -943,61 +942,99 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" + [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" dependencies = [ + "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" [[package]] name = "icu_properties" -version = "2.2.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" dependencies = [ + "displaydoc", "icu_collections", - "icu_locale_core", + "icu_locid_transform", "icu_properties_data", "icu_provider", - "zerotrie", + "tinystr", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" [[package]] name = "icu_provider" -version = "2.2.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" dependencies = [ "displaydoc", - "icu_locale_core", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", "writeable", "yoke", "zerofrom", - "zerotrie", "zerovec", ] +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -1017,9 +1054,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.2" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" dependencies = [ "icu_normalizer", "icu_properties", @@ -1027,9 +1064,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", "hashbrown", @@ -1037,9 +1074,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "itertools" @@ -1058,13 +1095,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -1122,15 +1158,15 @@ checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "libc", ] @@ -1143,9 +1179,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" [[package]] name = "lock_api" @@ -1158,18 +1194,18 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" dependencies = [ "value-bag", ] [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "makegov-tango" @@ -1177,7 +1213,7 @@ version = "0.2.0" dependencies = [ "bon", "futures", - "http 1.4.0", + "http 1.5.0", "httpmock", "pretty_assertions", "reqwest", @@ -1195,7 +1231,7 @@ version = "0.2.0" dependencies = [ "hex", "hmac", - "http 1.4.0", + "http 1.5.0", "pretty_assertions", "sha2", "subtle", @@ -1203,15 +1239,15 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mio" -version = "1.2.0" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", @@ -1327,15 +1363,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1368,14 +1395,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -1393,8 +1420,8 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", - "thiserror 2.0.18", + "socket2 0.6.5", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -1402,9 +1429,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "bytes", "getrandom 0.3.4", @@ -1415,7 +1442,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -1430,16 +1457,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.6.5", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -1452,9 +1479,9 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", "rand_core", @@ -1501,9 +1528,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -1513,9 +1540,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1524,9 +1551,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -1538,10 +1565,10 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "http-body-util", - "hyper 1.9.0", + "hyper 1.11.1", "hyper-rustls", "hyper-util", "js-sys", @@ -1585,15 +1612,15 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags", "errno", @@ -1604,9 +1631,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "ring", @@ -1618,9 +1645,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -1628,9 +1655,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -1639,9 +1666,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -1666,9 +1693,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1676,29 +1703,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1709,9 +1736,9 @@ dependencies = [ [[package]] name = "serde_regex" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" +checksum = "bafc8d0c5330cecff10f16b459b479fd9acaa5b4acd7167301414e21b0057012" dependencies = [ "regex", "serde", @@ -1742,9 +1769,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -1776,9 +1803,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "socket2" @@ -1792,9 +1819,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -1843,9 +1870,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -1869,7 +1907,18 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", ] [[package]] @@ -1894,11 +1943,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -1909,18 +1958,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -1934,9 +1983,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" dependencies = [ "displaydoc", "zerovec", @@ -1944,51 +1993,42 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", "mio", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -1996,9 +2036,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -2018,9 +2058,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", @@ -2046,15 +2086,15 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.10" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "pin-project-lite", "tower", "tower-layer", @@ -2101,15 +2141,15 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-xid" @@ -2135,6 +2175,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2143,9 +2189,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "value-bag" -version = "1.12.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" +checksum = "2799ffb329a792ecfd902b71306c8a815a6ef1c0470fa9953a6aa4d4cecbe511" [[package]] name = "version_check" @@ -2180,18 +2226,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -2202,9 +2248,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -2212,9 +2258,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2222,22 +2268,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] @@ -2257,9 +2303,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -2277,9 +2323,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -2483,11 +2529,17 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" -version = "0.6.3" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" [[package]] name = "yansi" @@ -2497,10 +2549,11 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.2" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" dependencies = [ + "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -2508,34 +2561,34 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", - "synstructure", + "syn 2.0.119", + "synstructure 0.13.2", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2549,14 +2602,14 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", - "synstructure", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] @@ -2565,22 +2618,11 @@ version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - [[package]] name = "zerovec" -version = "0.11.6" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" dependencies = [ "yoke", "zerofrom", @@ -2589,17 +2631,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "3e3c6377872d72510393f688a555d7097b0f741995c7a00f0407f786dd486b2d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" From def8edab0f97f8a7faa49bf4e34cc3d9b7d8d896 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Wed, 23 Sep 2026 10:36:38 -0500 Subject: [PATCH 4/5] feat!: remove search_opportunity_attachments The API retired `/api/opportunities/attachment-search/`, which returns 404 for every query, and 0.2.0 already breaks the API surface, so the method and `SearchOpportunityAttachmentsOptions` go now instead of carrying a deprecation. `list_opportunities` with `search` matches attachment text and returns a `snippet` for the hit. The changelog entry moves from Deprecated to Breaking. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 7 +- README.md | 2 +- ROADMAP.md | 3 +- crates/tango/src/resources/mod.rs | 1 - crates/tango/src/resources/opportunities.rs | 113 +------------------- docs/API_REFERENCE.md | 5 +- docs/ARCHITECTURE.md | 2 +- 7 files changed, 8 insertions(+), 125 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10fe112..f58b823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,14 @@ This project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] -Pre-1.0 (SemVer 0.x): the removal under **Breaking** ships without a deprecation cycle. +Pre-1.0 (SemVer 0.x): the removals under **Breaking** ship without a deprecation cycle. ### `makegov-tango` #### Breaking - **Removed `get_idv_summary` and `list_idv_summary_awards`.** They called `/api/idvs/{key}/summary/` and `/api/idvs/{key}/summary/awards/`, which have never existed in the Tango API, so every call returned 404. Use `get_idv` with `SHAPE_IDVS_COMPREHENSIVE` and `list_idv_awards` instead. +- **Removed `search_opportunity_attachments` and `SearchOpportunityAttachmentsOptions`.** The API retired `/api/opportunities/attachment-search/`: it returns 404 for every query and keeps the route only so a missing `q` still gets its 400. `list_opportunities` with `search` matches attachment text and returns a `snippet` for the hit. #### Added @@ -49,10 +50,6 @@ Pre-1.0 (SemVer 0.x): the removal under **Breaking** ships without a deprecation - **`SHAPE_SLED_OPPORTUNITIES_MINIMAL` and `SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE` now include `delisted_at`**, matching the API's own default shapes. It is when the portal stopped listing a solicitation before its deadline, and it is what `status_reason = "delisted"` refers to. -#### Deprecated - -- **`search_opportunity_attachments`.** The API retired `/api/opportunities/attachment-search/`: it returns 404 for every query and keeps the route only so a missing `q` still gets its 400. `list_opportunities` with `search` matches attachment text and returns a `snippet` for the hit. The method will be removed in a later release. - #### Fixed - **Protest enum values are documented in the casing the API returns.** `source_system` is lowercase (`gao`, `cofc`, `sba_oha`) and `outcome` is title-case (`Sustained`, `Denied`, …), not `"GAO"` / `"sustained"` as the rustdoc on `ListProtestsOptions` and `ProtestRecord` previously said. Filters were always case-insensitive, but code comparing returned values against the old examples would miss every match. The protests docs now also name SBA OHA as a source and list its extra outcomes (`Granted`, `Remanded`, `Reversed`, `Vacated`). diff --git a/README.md b/README.md index 9dfa140..7d3a9f7 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ The SDK exposes a method on `Client` for every public endpoint the sibling SDKs | NAICS / PSC | `list_naics` / `list_psc` | `get_naics` / `get_psc` | — | | Webhooks (CRUD) | `list_webhook_endpoints` / `list_webhook_alerts` | `get_` / `create_` / `update_` / `delete_` / `test_` | — | -Sub-resources and lookups: `list_contract_subawards` / `_transactions`, `list_entity_contracts` / `_idvs` / `_otas` / `_otidvs` / `_subawards` / `_lcats` / `get_entity_metrics` / `get_entity_budget_flows`, `get_budget_account_quarters` / `_recipients`, `list_sled_opportunity_revisions` / `get_sled_coverage`, `list_idv_awards` / `_child_idvs` / `_transactions` / `_lcats`, `list_agency_awarding_contracts` / `_funding_contracts`, `list_vehicle_awardees` / `_orders`, `list_otidv_awards`, `list_gsa_elibrary_contracts`, `list_business_types`, `list_offices`, `list_departments`, `list_mas_sins`, `list_assistance_listings`, `list_lcats` (dispatcher). Meta: `resolve`, `validate`, `get_version`, `list_api_keys`. `search_opportunity_attachments` is deprecated: the API retired that endpoint, and `list_opportunities` with `search` covers attachment text. Metrics dispatcher: `list_metrics`. +Sub-resources and lookups: `list_contract_subawards` / `_transactions`, `list_entity_contracts` / `_idvs` / `_otas` / `_otidvs` / `_subawards` / `_lcats` / `get_entity_metrics` / `get_entity_budget_flows`, `get_budget_account_quarters` / `_recipients`, `list_sled_opportunity_revisions` / `get_sled_coverage`, `list_idv_awards` / `_child_idvs` / `_transactions` / `_lcats`, `list_agency_awarding_contracts` / `_funding_contracts`, `list_vehicle_awardees` / `_orders`, `list_otidv_awards`, `list_gsa_elibrary_contracts`, `list_business_types`, `list_offices`, `list_departments`, `list_mas_sins`, `list_assistance_listings`, `list_lcats` (dispatcher). Meta: `resolve`, `validate`, `get_version`, `list_api_keys`. `list_opportunities` with `search` also matches attachment text. Metrics dispatcher: `list_metrics`. See [`docs/API_REFERENCE.md`](docs/API_REFERENCE.md) for full signatures, filter fields, and quirks. diff --git a/ROADMAP.md b/ROADMAP.md index e373eda..9ee4ed5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,7 +22,7 @@ This roadmap tracks the Rust SDK only. The goal is to stay closely aligned with - [X] Singleton detail GETs: `get_contract`, `get_opportunity`, `get_notice`, `get_forecast`, `get_grant`, `get_subaward`; contract sub-routes `list_contract_subawards` / `list_contract_transactions`. - [X] Filter parity with the live API on every list endpoint the SDK covers. - [X] State, local and education (SLED) procurement; contract appeals. -- [X] Removed the IDV summary methods, whose paths never existed upstream. +- [X] Removed the IDV summary methods, whose paths never existed upstream, and `search_opportunity_attachments`, whose endpoint the API retired. ## Next @@ -31,7 +31,6 @@ This roadmap tracks the Rust SDK only. The goal is to stay closely aligned with - [ ] Newtype identifiers for `Uei`, `EntityId`, `AgencyId`, etc. (compile-time safety, no string typo bugs). - [ ] Comprehensive integration tests against the live Tango API. - [ ] Typed structs for remaining lookup/metric endpoints (NAICS, PSC, entity metrics). -- [ ] Remove the deprecated `search_opportunity_attachments` (the API retired its endpoint). ## Later (0.3) diff --git a/crates/tango/src/resources/mod.rs b/crates/tango/src/resources/mod.rs index cdd76d6..3d68b4e 100644 --- a/crates/tango/src/resources/mod.rs +++ b/crates/tango/src/resources/mod.rs @@ -59,7 +59,6 @@ pub use metrics::{ }; pub use opportunities::{ ListForecastsOptions, ListGrantsOptions, ListNoticesOptions, ListOpportunitiesOptions, - SearchOpportunityAttachmentsOptions, }; pub use otas::{ GetOTAOptions, GetOTIDVOptions, ListOTAsOptions, ListOTIDVAwardsOptions, ListOTIDVsOptions, diff --git a/crates/tango/src/resources/opportunities.rs b/crates/tango/src/resources/opportunities.rs index 3321bd4..670e91e 100644 --- a/crates/tango/src/resources/opportunities.rs +++ b/crates/tango/src/resources/opportunities.rs @@ -3,7 +3,7 @@ use crate::client::Client; use crate::error::{Error, Result}; -use crate::internal::{apply_pagination, push_opt, push_opt_bool, push_opt_u32, ListOptions}; +use crate::internal::{apply_pagination, push_opt, push_opt_bool, ListOptions}; use crate::pagination::{FetchFn, Page, PageStream}; use crate::resources::agencies::urlencoding; use crate::Record; @@ -573,42 +573,6 @@ impl ListGrantsOptions { } } -// --------------------------------------------------------------------------- -// Opportunity attachment search -// --------------------------------------------------------------------------- - -/// Options for [`Client::search_opportunity_attachments`], which targets a retired endpoint. -/// -/// `q` is required; an empty `q` causes the call to return -/// [`Error::Validation`] before any network request. -#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)] -#[non_exhaustive] -pub struct SearchOpportunityAttachmentsOptions { - /// Natural-language query. Required. - #[builder(into)] - pub q: Option, - /// Maximum number of matches to return. `None` / `0` means - /// "use the server default". - #[builder(into)] - pub top_k: Option, - /// When true, returns the matched attachment text alongside metadata. - /// Defaults to false to keep responses small. - #[builder(default)] - pub include_extracted_text: bool, -} - -impl SearchOpportunityAttachmentsOptions { - fn to_query(&self) -> Vec<(String, String)> { - let mut q = Vec::new(); - push_opt(&mut q, "q", self.q.as_deref()); - push_opt_u32(&mut q, "top_k", self.top_k); - if self.include_extracted_text { - q.push(("include_extracted_text".into(), "true".into())); - } - q - } -} - // --------------------------------------------------------------------------- // Client methods // --------------------------------------------------------------------------- @@ -749,30 +713,6 @@ impl Client { let path = format!("/api/grants/{}/", urlencoding(grant_id)); self.get_json::(&path, &q).await } - - /// `GET /api/opportunities/attachment-search/` — retired semantic search over opportunity attachments. - /// - /// The API retired this endpoint: it now returns 404 for every query, and keeps the route only so a missing `q` still gets its 400. - /// Search attachment text through the `search` filter on [`Client::list_opportunities`] instead. - /// Returns [`Error::Validation`] when `opts.q` is missing or empty. - #[deprecated( - since = "0.2.0", - note = "the API retired /api/opportunities/attachment-search/ and returns 404 for every query; use list_opportunities with `search`" - )] - pub async fn search_opportunity_attachments( - &self, - opts: SearchOpportunityAttachmentsOptions, - ) -> Result { - if opts.q.as_deref().filter(|s| !s.is_empty()).is_none() { - return Err(Error::Validation { - message: "search_opportunity_attachments: q is required".into(), - response: None, - }); - } - let q = opts.to_query(); - self.get_json::("/api/opportunities/attachment-search/", &q) - .await - } } #[cfg(test)] @@ -947,57 +887,6 @@ mod tests { } } - #[test] - fn attachment_search_emits_all_flags() { - let opts = SearchOpportunityAttachmentsOptions::builder() - .q("statement of work cloud migration") - .top_k(5u32) - .include_extracted_text(true) - .build(); - let q = opts.to_query(); - assert_eq!( - get_q(&q, "q").as_deref(), - Some("statement of work cloud migration") - ); - assert_eq!(get_q(&q, "top_k").as_deref(), Some("5")); - assert_eq!(get_q(&q, "include_extracted_text").as_deref(), Some("true")); - } - - #[test] - fn attachment_search_top_k_zero_omitted() { - let opts = SearchOpportunityAttachmentsOptions::builder() - .q("test query") - .top_k(0u32) - .build(); - let q = opts.to_query(); - assert!(!q.iter().any(|(k, _)| k == "top_k")); - } - - #[test] - fn attachment_search_extracted_text_omitted_when_false() { - let opts = SearchOpportunityAttachmentsOptions::builder() - .q("test") - .build(); - let q = opts.to_query(); - assert!(!q.iter().any(|(k, _)| k == "include_extracted_text")); - } - - #[tokio::test] - #[allow(deprecated)] - async fn search_opportunity_attachments_empty_q_returns_validation() { - let client = Client::builder().api_key("x").build().expect("build"); - let err = client - .search_opportunity_attachments(SearchOpportunityAttachmentsOptions::default()) - .await - .expect_err("must error"); - match err { - Error::Validation { message, .. } => { - assert!(message.contains('q')); - } - other => panic!("expected Validation, got {other:?}"), - } - } - #[test] fn identifier_and_org_filters_emit() { let q = ListNoticesOptions::builder() diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index c44d6c2..c3c3d89 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -90,11 +90,10 @@ Options: `ListVehiclesOptions`, `GetVehicleOptions`, `ListVehicleAwardeesOptions | `get_forecast(id, opts)` | `GET /api/forecasts/{id}/` | `Record` | | `list_grants(opts)` / `iterate_*` | `GET /api/grants/` | `Page` / `PageStream` | | `get_grant(grant_id, opts)` | `GET /api/grants/{grant_id}/` | `Record` | -| `search_opportunity_attachments(opts)` *(deprecated)* | `GET /api/opportunities/attachment-search/` | `Record` | -Options: `ListOpportunitiesOptions`, `ListNoticesOptions`, `ListForecastsOptions`, `ListGrantsOptions`, `SearchOpportunityAttachmentsOptions`. The singleton `get_*` methods take `Option`. `ListGrantsOptions` exposes a typed `grant_id` filter; `ListNoticesOptions` adds `notice_id`, `department` and `office`; `ListOpportunitiesOptions` adds `opportunity_id`; `ListForecastsOptions` adds `id`. +Options: `ListOpportunitiesOptions`, `ListNoticesOptions`, `ListForecastsOptions`, `ListGrantsOptions`. The singleton `get_*` methods take `Option`. `ListGrantsOptions` exposes a typed `grant_id` filter; `ListNoticesOptions` adds `notice_id`, `department` and `office`; `ListOpportunitiesOptions` adds `opportunity_id`; `ListForecastsOptions` adds `id`. -**`search_opportunity_attachments` is deprecated.** The API retired `/api/opportunities/attachment-search/`: it returns 404 for every query and keeps the route only so a missing `q` still gets its 400. `list_opportunities` with `search` matches attachment text and returns a `snippet` for the hit. +**There is no attachment-search method.** The API retired `/api/opportunities/attachment-search/` (it returns 404 for every query). `list_opportunities` with `search` matches attachment text and returns a `snippet` for the hit. ### OTAs / OTIDVs (`otas.rs`) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7cd514d..66217eb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -37,7 +37,7 @@ tango-rust/ (workspace root) │ │ ├── entity_subresources.rs │ │ ├── vehicles.rs │ │ ├── vehicle_subresources.rs -│ │ ├── opportunities.rs # opps / notices / forecasts / grants / attachment search +│ │ ├── opportunities.rs # opps / notices / forecasts / grants │ │ ├── otas.rs # OTAs + OTIDVs │ │ ├── subawards.rs │ │ ├── gsa.rs From 73f1e8489ef2e18fbcb2a0ff3b8ebde76a209632 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Wed, 23 Sep 2026 10:40:10 -0500 Subject: [PATCH 5/5] chore(release): v0.2.0 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f58b823..8436a71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ This project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.2.0] — 2026-09-23 + Pre-1.0 (SemVer 0.x): the removals under **Breaking** ship without a deprecation cycle. ### `makegov-tango`